From c4bdb8f20939e599bd43b542117140aa59dc2d82 Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 5 May 2026 18:13:05 +1000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20show=20task=5Fawait=20ela?= =?UTF-8?q?psed=20timing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show live elapsed time while task_await calls are executing and include elapsed_ms on sub-agent task_await results when task timestamps are available. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$17.34`_ --- .../Tools/Shared/ElapsedTimeDisplay.tsx | 17 +++- .../features/Tools/TaskToolCall.test.tsx | 81 +++++++++++++++++++ src/browser/features/Tools/TaskToolCall.tsx | 49 ++++++++++- src/node/services/taskService.ts | 20 +++++ src/node/services/tools/task_await.test.ts | 70 +++++++++++++++- src/node/services/tools/task_await.ts | 38 ++++++++- 6 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 src/browser/features/Tools/TaskToolCall.test.tsx diff --git a/src/browser/features/Tools/Shared/ElapsedTimeDisplay.tsx b/src/browser/features/Tools/Shared/ElapsedTimeDisplay.tsx index 513e59f80e..d758c1d85b 100644 --- a/src/browser/features/Tools/Shared/ElapsedTimeDisplay.tsx +++ b/src/browser/features/Tools/Shared/ElapsedTimeDisplay.tsx @@ -3,13 +3,20 @@ import React, { useEffect, useRef } from "react"; interface ElapsedTimeDisplayProps { startedAt: number | undefined; isActive: boolean; + prefix?: string; + separator?: string; } /** * Shared elapsed time display for tool headers. * Keeps requestAnimationFrame + per-second updates at the leaf so parent tool calls do not re-render. */ -export const ElapsedTimeDisplay: React.FC = ({ startedAt, isActive }) => { +export const ElapsedTimeDisplay: React.FC = ({ + startedAt, + isActive, + prefix = "", + separator = " • ", +}) => { const elapsedRef = useRef(0); const frameRef = useRef(null); const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0); @@ -57,5 +64,11 @@ export const ElapsedTimeDisplay: React.FC = ({ startedA return null; } - return <> • {Math.round(elapsedRef.current / 1000)}s; + return ( + <> + {separator} + {prefix} + {Math.round(elapsedRef.current / 1000)}s + + ); }; diff --git a/src/browser/features/Tools/TaskToolCall.test.tsx b/src/browser/features/Tools/TaskToolCall.test.tsx new file mode 100644 index 0000000000..e4705acfe5 --- /dev/null +++ b/src/browser/features/Tools/TaskToolCall.test.tsx @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { cleanup, render } from "@testing-library/react"; +import { GlobalWindow } from "happy-dom"; + +import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; + +void mock.module("./SubagentTranscriptDialog", () => ({ + SubagentTranscriptDialog: () => null, +})); + +void mock.module("./Shared/ElapsedTimeDisplay", () => ({ + ElapsedTimeDisplay: ({ + startedAt, + isActive, + prefix, + separator, + }: { + startedAt: number | undefined; + isActive: boolean; + prefix?: string; + separator?: string; + }) => ( + + ), +})); + +import { getToolComponent } from "./Shared/getToolComponent"; + +const taskAwaitArgs = { task_ids: ["task-1"], timeout_secs: 70 }; +const TaskAwaitToolCall = getToolComponent("task_await", taskAwaitArgs); + +function renderTaskAwaitToolCall(props: Record = {}) { + return render( + + + + ); +} + +describe("TaskAwaitToolCall", () => { + let originalWindow: typeof globalThis.window; + let originalDocument: typeof globalThis.document; + + beforeEach(() => { + originalWindow = globalThis.window; + originalDocument = globalThis.document; + + globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; + globalThis.document = globalThis.window.document; + }); + + afterEach(() => { + cleanup(); + mock.restore(); + globalThis.window = originalWindow; + globalThis.document = originalDocument; + }); + + test("shows elapsed time while task_await is executing", () => { + const startedAt = 1_700_000_000_123; + + const view = renderTaskAwaitToolCall({ startedAt }); + + const timer = view.getByTestId("elapsed-time"); + expect(timer.dataset.active).toBe("true"); + expect(timer.dataset.startedAt).toBe(String(startedAt)); + expect(timer.dataset.prefix).toBe("elapsed "); + expect(timer.dataset.separator).toBe(""); + }); +}); diff --git a/src/browser/features/Tools/TaskToolCall.tsx b/src/browser/features/Tools/TaskToolCall.tsx index 93f65d9f71..02d47688d1 100644 --- a/src/browser/features/Tools/TaskToolCall.tsx +++ b/src/browser/features/Tools/TaskToolCall.tsx @@ -55,6 +55,8 @@ import { normalizeTaskGroupLabel, type TaskGroupKind, } from "@/common/utils/tools/taskGroups"; +import { formatDuration } from "@/common/utils/formatDuration"; +import { ElapsedTimeDisplay } from "./Shared/ElapsedTimeDisplay"; /** * Clean SVG icon for task tools - represents spawning/branching work @@ -201,9 +203,35 @@ interface TaskRowProps { agentType?: string; title?: string; depth?: number; + startedAtMs?: number; className?: string; } +function isTaskRowElapsedActive(status: string): boolean { + return status === "queued" || status === "running" || status === "awaiting_report"; +} + +const TaskRowElapsed: React.FC<{ startedAtMs: number | undefined; status: string }> = (props) => { + if (props.startedAtMs == null) { + return null; + } + + if (isTaskRowElapsedActive(props.status)) { + return ( + + + + ); + } + + return null; +}; + const TaskRow: React.FC = (props) => (
= (props) => ( {typeof props.depth === "number" && props.depth > 0 && ( depth: {props.depth} )} +
); @@ -944,6 +973,7 @@ interface TaskAwaitToolCallProps { args: TaskAwaitToolArgs; result?: TaskAwaitToolSuccessResult; status?: ToolStatus; + startedAt?: number; taskReportLinking?: TaskReportLinking; } @@ -951,6 +981,7 @@ export const TaskAwaitToolCall: React.FC = ({ args, result, status = "pending", + startedAt, taskReportLinking, }) => { const taskIds = args.task_ids; @@ -986,6 +1017,7 @@ export const TaskAwaitToolCall: React.FC = ({ status: proc ? toTaskStatusFromBackgroundProcessStatus(proc.status) : "waiting", title: proc?.displayName ?? proc?.id, depth: 1, + startedAtMs: proc?.startTime, }); continue; } @@ -1008,6 +1040,7 @@ export const TaskAwaitToolCall: React.FC = ({ workspaceId && workspaceMetadata ? computeWorkspaceDepthFromRoot(workspaceId, taskId, workspaceMetadata) : undefined, + startedAtMs: parseWorkspaceCreatedAtMs(metadata.createdAt), }); } } @@ -1027,6 +1060,16 @@ export const TaskAwaitToolCall: React.FC = ({ task_await + {status === "executing" && ( + + + + )} {totalCount > 0 && ( {completedCount}/{totalCount} completed @@ -1128,7 +1171,11 @@ const TaskAwaitResult: React.FC<{ {title && {title}} {exitCode !== undefined && exit {exitCode}} - {elapsedMs !== undefined && {elapsedMs}ms} + {elapsedMs !== undefined && ( + + took {formatDuration(elapsedMs)} + + )} {note && ( diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 79d4d2d390..bea5c35487 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -93,6 +93,11 @@ export interface AgentTaskStatusLookup { taskStatus: AgentTaskStatus | null; } +export interface AgentTaskTimestamps { + createdAt?: string; + reportedAt?: string; +} + export interface TaskCreateArgs { parentWorkspaceId: string; kind: TaskKind; @@ -1907,6 +1912,21 @@ export class TaskService { return status ?? null; } + getAgentTaskTimestamps(taskId: string): AgentTaskTimestamps | null { + assert(taskId.length > 0, "getAgentTaskTimestamps: taskId must be non-empty"); + + const cfg = this.config.loadConfigOrDefault(); + const entry = findWorkspaceEntry(cfg, taskId); + if (!entry) { + return null; + } + + return { + createdAt: entry.workspace.createdAt, + reportedAt: entry.workspace.reportedAt, + }; + } + getAgentTaskStatuses(taskIds: string[]): Map { for (const taskId of taskIds) { assert(taskId.length > 0, "getAgentTaskStatuses: taskId must be non-empty"); diff --git a/src/node/services/tools/task_await.test.ts b/src/node/services/tools/task_await.test.ts index 694514f448..1df96ede31 100644 --- a/src/node/services/tools/task_await.test.ts +++ b/src/node/services/tools/task_await.test.ts @@ -1,6 +1,6 @@ import * as fs from "fs"; -import { describe, it, expect, mock } from "bun:test"; +import { describe, it, expect, mock, spyOn } from "bun:test"; import type { ToolExecutionOptions } from "ai"; import { createTaskAwaitTool } from "./task_await"; @@ -186,6 +186,74 @@ describe("task_await tool", () => { ); }); + it("includes elapsed_ms for completed agent task results when timestamps are available", async () => { + using tempDir = new TestTempDir("test-task-await-tool-agent-elapsed-completed"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const taskService = { + listActiveDescendantAgentTaskIds: mock(() => ["t1"]), + isDescendantAgentTask: mock(() => Promise.resolve(true)), + waitForAgentReport: mock(() => Promise.resolve({ reportMarkdown: "ok" })), + getAgentTaskTimestamps: mock(() => ({ + createdAt: "2026-01-01T00:00:00.000Z", + reportedAt: "2026-01-01T00:00:02.500Z", + })), + } as unknown as TaskService; + + const tool = createTaskAwaitTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve( + tool.execute!({ task_ids: ["t1"] }, mockToolCallOptions) + ); + + expect(result).toEqual({ + results: [ + { + status: "completed", + taskId: "t1", + reportMarkdown: "ok", + title: undefined, + elapsed_ms: 2500, + }, + ], + }); + }); + + it("includes elapsed_ms for active agent task results when timestamps are available", async () => { + using tempDir = new TestTempDir("test-task-await-tool-agent-elapsed-active"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + const nowMs = Date.parse("2026-01-01T00:00:05.000Z"); + const dateNowSpy = spyOn(Date, "now").mockReturnValue(nowMs); + + try { + const waitForAgentReport = mock(() => { + throw new Error("waitForAgentReport should not be called for timeout_secs=0"); + }); + const taskService = { + listActiveDescendantAgentTaskIds: mock(() => ["t1"]), + isDescendantAgentTask: mock(() => Promise.resolve(true)), + getAgentTaskStatus: mock(() => "running" as const), + getAgentTaskTimestamps: mock(() => ({ + createdAt: "2026-01-01T00:00:02.000Z", + })), + waitForAgentReport, + } as unknown as TaskService; + + const tool = createTaskAwaitTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve( + tool.execute!({ timeout_secs: 0 }, mockToolCallOptions) + ); + + expect(result).toEqual({ + results: [{ status: "running", taskId: "t1", elapsed_ms: 3000 }], + }); + expect(waitForAgentReport).toHaveBeenCalledTimes(0); + } finally { + dateNowSpy.mockRestore(); + } + }); + it("does not list background bash tasks when explicit agent task IDs are valid", async () => { using tempDir = new TestTempDir("test-task-await-tool-explicit-valid-agent-with-bash-manager"); const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); diff --git a/src/node/services/tools/task_await.ts b/src/node/services/tools/task_await.ts index c752ad5014..b8c6680039 100644 --- a/src/node/services/tools/task_await.ts +++ b/src/node/services/tools/task_await.ts @@ -16,6 +16,7 @@ import { getErrorMessage } from "@/common/utils/errors"; import { ForegroundWaitBackgroundedError, type AgentTaskStatusLookup, + type AgentTaskTimestamps, } from "@/node/services/taskService"; function coerceTimeoutMs(timeoutSecs: unknown): number | undefined { @@ -25,6 +26,31 @@ function coerceTimeoutMs(timeoutSecs: unknown): number | undefined { return timeoutMs; } +function parseTimestampMs(value: string | undefined): number | undefined { + if (value == null) { + return undefined; + } + + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : undefined; +} + +function getAgentTaskElapsedMs( + timestamps: AgentTaskTimestamps | null | undefined +): number | undefined { + const createdAtMs = parseTimestampMs(timestamps?.createdAt); + if (createdAtMs == null) { + return undefined; + } + + const endAtMs = parseTimestampMs(timestamps?.reportedAt) ?? Date.now(); + return Math.max(0, endAtMs - createdAtMs); +} + +function withElapsedMs(elapsedMs: number | undefined): { elapsed_ms?: number } { + return elapsedMs == null ? {} : { elapsed_ms: elapsedMs }; +} + function buildTaskAwaitSequencingError(taskId: string, suggestedTaskIds: string[]) { return { status: "error" as const, @@ -104,6 +130,11 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { return await readSubagentGitPatchArtifact(config.workspaceSessionDir, childTaskId); }; + // Agent task records currently store creation/report timestamps, but not a separate + // running-start timestamp, so this elapsed value intentionally includes queued time. + const getAgentTaskElapsedField = (taskId: string) => + withElapsedMs(getAgentTaskElapsedMs(taskService.getAgentTaskTimestamps?.(taskId))); + const descendantAgentTaskIds = typeof bulkFilter === "function" ? await bulkFilter.call(taskService, workspaceId, agentTaskIds) @@ -217,7 +248,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { if (timeoutMs === 0) { const status = taskService.getAgentTaskStatus(taskId); if (status === "queued" || status === "running" || status === "awaiting_report") { - return { status, taskId }; + return { status, taskId, ...getAgentTaskElapsedField(taskId) }; } // Best-effort: the task might already have a cached report (even if its workspace was @@ -236,6 +267,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { taskId, reportMarkdown: report.reportMarkdown, title: report.title, + ...getAgentTaskElapsedField(taskId), ...(gitFormatPatch ? { artifacts: { gitFormatPatch } } : {}), }; } catch (error: unknown) { @@ -261,6 +293,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { taskId, reportMarkdown: report.reportMarkdown, title: report.title, + ...getAgentTaskElapsedField(taskId), ...(gitFormatPatch ? { artifacts: { gitFormatPatch } } : {}), }; } catch (error: unknown) { @@ -275,6 +308,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { return { status: normalizedStatus, taskId, + ...getAgentTaskElapsedField(taskId), note: "Task sent to background because a new message was queued. Use task_await to monitor progress.", }; } @@ -290,7 +324,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { if (/timed out/i.test(message)) { const status = taskService.getAgentTaskStatus(taskId); if (status === "queued" || status === "running" || status === "awaiting_report") { - return { status, taskId }; + return { status, taskId, ...getAgentTaskElapsedField(taskId) }; } if (!status) { return { status: "not_found" as const, taskId };