feat(agents): add thinking time logging between tool calls (#244)

* feat(agents): add thinking time logging between tool calls

Adds a ThinkingTimer utility that tracks the gap between tool results and
the next tool call. When the gap exceeds 3 seconds, it logs the duration
with a stopwatch emoji (⏱️ 4.2s).

Uses performance.now() for high-resolution timing and Intl.NumberFormat
for rendering duration in seconds with optional fraction digits.

Integrated across all 5 agents: Claude, Codex, Cursor, Gemini, OpenCode.

Closes #127

* fix: adjusting tests for mocking performance.now.

* fix: reducing diff for claude.

* fix: rm unused args for claude.

* rm unused args for codex.

* fix: rm unused args for gemini.

* fix: rm unused args for opencode.

* mv THINKING_THRESHOLD.

* rev: I decided to pospone node:perf_hooks integration since it requires more comprehensive refactoring.

* fix: using Intl unit formatting.

* tests for ThinkingTimer.

* fix: narrow unit.

* fix: making durationFormatter a class instance property since using one agent per run.

* fix: inverting condition in markToolCall.

* thinking timer improvements and fix actions/checkout v6 auth

- thinking timer: use » chevron and "thought for X seconds" format
- thinking timer: add debug timestamps for sanity checking
- demote PID namespace isolation logs to debug
- remove redundant "setting up git authentication" log
- fix duplicate Authorization header with actions/checkout v6: clean up
  includeIf credential entries that v6 persists via external config files

Co-authored-by: Cursor <cursoragent@cursor.com>

* standardize tool call log prefix to » double chevron

Co-authored-by: Cursor <cursoragent@cursor.com>

* update timer tests for new thinking log format

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Robin Tail <robin_tail@me.com>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pullfrog[bot]
2026-02-10 06:17:43 +00:00
committed by pullfrog[bot]
parent f67cc25f74
commit fb80343ffd
13 changed files with 294 additions and 54 deletions
+2 -2
View File
@@ -261,8 +261,8 @@ export const log = {
const timestamp = isDebugEnabled() ? ` [${new Date().toISOString()}]` : "";
const output =
inputFormatted !== "{}"
? ` ${toolName}(${inputFormatted})${timestamp}`
: ` ${toolName}()${timestamp}`;
? `» ${toolName}(${inputFormatted})${timestamp}`
: `» ${toolName}()${timestamp}`;
log.info(output.trimEnd());
},
+23 -2
View File
@@ -120,8 +120,6 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
}
// 2. setup authentication
log.info("» setting up git authentication...");
// remove existing git auth headers that actions/checkout might have set
try {
execSync("git config --local --unset-all http.https://github.com/.extraheader", {
@@ -133,6 +131,29 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
log.debug("» no existing authentication headers to remove");
}
// remove includeIf entries that actions/checkout@v6 uses for credential persistence.
// v6 stores credentials in an external file loaded via includeIf.gitdir, which our
// --unset-all above doesn't catch. without this, $git() would produce duplicate
// Authorization headers (one from includeIf, one from GIT_CONFIG_PARAMETERS).
try {
const configOutput = execSync("git config --local --get-regexp ^includeif\\.", {
cwd: repoDir,
encoding: "utf-8",
stdio: "pipe",
});
for (const line of configOutput.trim().split("\n")) {
const key = line.split(" ")[0];
if (!key) continue;
execSync(`git config --local --unset "${key}"`, {
cwd: repoDir,
stdio: "pipe",
});
}
log.info("» removed includeIf credential entries");
} catch {
log.debug("» no includeIf credential entries to remove");
}
// SECURITY: set origin URL without token - auth is injected via GIT_CONFIG_PARAMETERS
// in $git() calls. this prevents token leakage to git hooks and subprocesses.
const originUrl = `https://github.com/${params.owner}/${params.name}.git`;
+107 -1
View File
@@ -1,5 +1,5 @@
import * as cli from "./cli.ts";
import { Timer } from "./timer.ts";
import { ThinkingTimer, Timer } from "./timer.ts";
describe("Timer", () => {
beforeEach(() => {
@@ -104,3 +104,109 @@ describe("Timer", () => {
});
});
});
describe("ThinkingTimer", () => {
beforeEach(() => {
vi.spyOn(cli.log, "info");
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe("markToolResult", () => {
it("should store the current timestamp", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new ThinkingTimer();
timer.markToolResult();
vi.setSystemTime(startTime + 5000);
timer.markToolCall();
expect(cli.log.info).toHaveBeenCalled();
});
});
describe("markToolCall", () => {
it("should not log if markToolResult was never called", () => {
const timer = new ThinkingTimer();
timer.markToolCall();
expect(cli.log.info).not.toHaveBeenCalled();
});
it("should not log if elapsed time is below threshold (3000ms)", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new ThinkingTimer();
timer.markToolResult();
vi.setSystemTime(startTime + 2999);
timer.markToolCall();
expect(cli.log.info).not.toHaveBeenCalled();
});
it("should log if elapsed time equals threshold (3000ms)", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new ThinkingTimer();
timer.markToolResult();
vi.setSystemTime(startTime + 3000);
timer.markToolCall();
expect(cli.log.info).toHaveBeenCalledWith("» thought for 3 seconds");
});
it("should log if elapsed time exceeds threshold", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new ThinkingTimer();
timer.markToolResult();
vi.setSystemTime(startTime + 5500);
timer.markToolCall();
expect(cli.log.info).toHaveBeenCalledWith("» thought for 5.5 seconds");
});
it("should format large durations correctly", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new ThinkingTimer();
timer.markToolResult();
vi.setSystemTime(startTime + 15000);
timer.markToolCall();
expect(cli.log.info).toHaveBeenCalledWith("» thought for 15 seconds");
});
it("should handle multiple markToolCall invocations", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new ThinkingTimer();
timer.markToolResult();
vi.setSystemTime(startTime + 4000);
timer.markToolCall();
vi.setSystemTime(startTime + 5000);
timer.markToolCall();
expect(cli.log.info).toHaveBeenCalledTimes(2);
expect(cli.log.info).toHaveBeenNthCalledWith(1, "» thought for 4 seconds");
expect(cli.log.info).toHaveBeenNthCalledWith(2, "» thought for 5 seconds");
});
});
});
+29
View File
@@ -18,3 +18,32 @@ export class Timer {
this.lastCheckpointTimestamp = now;
}
}
const THINKING_THRESHOLD = 3000; // ms
export class ThinkingTimer {
private readonly durationFormatter = new Intl.NumberFormat("en-US", {
style: "unit",
unit: "second",
unitDisplay: "long",
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
private lastToolResultTimestamp: number | null = null;
markToolResult(): void {
this.lastToolResultTimestamp = Date.now();
log.debug(`» thinking timer: markToolResult at ${this.lastToolResultTimestamp}`);
}
markToolCall(): void {
const now = Date.now();
log.debug(`» thinking timer: markToolCall at ${now}, lastToolResult=${this.lastToolResultTimestamp}`);
if (this.lastToolResultTimestamp === null) return;
const elapsed = now - this.lastToolResultTimestamp;
if (elapsed < THINKING_THRESHOLD) return;
const seconds = elapsed / 1000;
log.info(`» thought for ${this.durationFormatter.format(seconds)}`);
}
}