Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/browser/features/Tools/Shared/ElapsedTimeDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ElapsedTimeDisplayProps> = ({ startedAt, isActive }) => {
export const ElapsedTimeDisplay: React.FC<ElapsedTimeDisplayProps> = ({
startedAt,
isActive,
prefix = "",
separator = " • ",
}) => {
const elapsedRef = useRef(0);
const frameRef = useRef<number | null>(null);
const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0);
Expand Down Expand Up @@ -57,5 +64,11 @@ export const ElapsedTimeDisplay: React.FC<ElapsedTimeDisplayProps> = ({ startedA
return null;
}

return <> • {Math.round(elapsedRef.current / 1000)}s</>;
return (
<>
{separator}
{prefix}
{Math.round(elapsedRef.current / 1000)}s
</>
);
};
81 changes: 81 additions & 0 deletions src/browser/features/Tools/TaskToolCall.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
}) => (
<span
data-testid="elapsed-time"
data-active={String(isActive)}
data-prefix={prefix ?? ""}
data-separator={separator ?? " • "}
data-started-at={startedAt == null ? "missing" : String(startedAt)}
/>
),
}));

import { getToolComponent } from "./Shared/getToolComponent";

const taskAwaitArgs = { task_ids: ["task-1"], timeout_secs: 70 };
const TaskAwaitToolCall = getToolComponent("task_await", taskAwaitArgs);

function renderTaskAwaitToolCall(props: Record<string, unknown> = {}) {
return render(
<TooltipProvider>
<TaskAwaitToolCall
args={taskAwaitArgs}
status="executing"
startedAt={1_700_000_000_000}
{...props}
/>
</TooltipProvider>
);
}

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("");
});
});
49 changes: 48 additions & 1 deletion src/browser/features/Tools/TaskToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<span className="text-muted counter-nums text-[10px]">
<ElapsedTimeDisplay
startedAt={props.startedAtMs}
isActive={true}
separator=""
prefix="elapsed "
/>
</span>
);
}

return null;
};

const TaskRow: React.FC<TaskRowProps> = (props) => (
<div
className={cn("bg-code-bg flex flex-wrap items-center gap-2 rounded-sm p-2", props.className)}
Expand All @@ -217,6 +245,7 @@ const TaskRow: React.FC<TaskRowProps> = (props) => (
{typeof props.depth === "number" && props.depth > 0 && (
<span className="text-muted text-[10px]">depth: {props.depth}</span>
)}
<TaskRowElapsed startedAtMs={props.startedAtMs} status={props.status} />
</div>
);

Expand Down Expand Up @@ -944,13 +973,15 @@ interface TaskAwaitToolCallProps {
args: TaskAwaitToolArgs;
result?: TaskAwaitToolSuccessResult;
status?: ToolStatus;
startedAt?: number;
taskReportLinking?: TaskReportLinking;
}

export const TaskAwaitToolCall: React.FC<TaskAwaitToolCallProps> = ({
args,
result,
status = "pending",
startedAt,
taskReportLinking,
}) => {
const taskIds = args.task_ids;
Expand Down Expand Up @@ -986,6 +1017,7 @@ export const TaskAwaitToolCall: React.FC<TaskAwaitToolCallProps> = ({
status: proc ? toTaskStatusFromBackgroundProcessStatus(proc.status) : "waiting",
title: proc?.displayName ?? proc?.id,
depth: 1,
startedAtMs: proc?.startTime,
});
continue;
}
Expand All @@ -1008,6 +1040,7 @@ export const TaskAwaitToolCall: React.FC<TaskAwaitToolCallProps> = ({
workspaceId && workspaceMetadata
? computeWorkspaceDepthFromRoot(workspaceId, taskId, workspaceMetadata)
: undefined,
startedAtMs: parseWorkspaceCreatedAtMs(metadata.createdAt),
});
}
}
Expand All @@ -1027,6 +1060,16 @@ export const TaskAwaitToolCall: React.FC<TaskAwaitToolCallProps> = ({
<ExpandIcon expanded={expanded}>â–¶</ExpandIcon>
<TaskIcon toolName="task_await" />
<ToolName>task_await</ToolName>
{status === "executing" && (
<span className="text-pending counter-nums ml-2 text-[10px] whitespace-nowrap [@container(max-width:500px)]:hidden">
<ElapsedTimeDisplay
startedAt={startedAt}
isActive={true}
separator=""
prefix="elapsed "
/>
</span>
)}
{totalCount > 0 && (
<span className="text-muted text-[10px]">
{completedCount}/{totalCount} completed
Expand Down Expand Up @@ -1128,7 +1171,11 @@ const TaskAwaitResult: React.FC<{
<TaskStatusBadge status={result.status} />
{title && <span className="text-foreground text-[11px] font-medium">{title}</span>}
{exitCode !== undefined && <span className="text-muted text-[10px]">exit {exitCode}</span>}
{elapsedMs !== undefined && <span className="text-muted text-[10px]">{elapsedMs}ms</span>}
{elapsedMs !== undefined && (
<span className="text-muted counter-nums text-[10px]">
took {formatDuration(elapsedMs)}
</span>
)}
{note && (
<Tooltip>
<TooltipTrigger asChild>
Expand Down
20 changes: 20 additions & 0 deletions src/node/services/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ export interface AgentTaskStatusLookup {
taskStatus: AgentTaskStatus | null;
}

export interface AgentTaskTimestamps {
createdAt?: string;
reportedAt?: string;
}

export interface TaskCreateArgs {
parentWorkspaceId: string;
kind: TaskKind;
Expand Down Expand Up @@ -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<string, AgentTaskStatusLookup> {
for (const taskId of taskIds) {
assert(taskId.length > 0, "getAgentTaskStatuses: taskId must be non-empty");
Expand Down
70 changes: 69 additions & 1 deletion src/node/services/tools/task_await.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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" });
Expand Down
Loading
Loading