From 483436c5a78b72cf97d24442e283c7cb5e709f03 Mon Sep 17 00:00:00 2001 From: q <167793812+0vp@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:31:03 -0400 Subject: [PATCH 1/2] Add per-turn timer and todo budget feedback --- README.md | 33 +++++ src/config/BackboardConfigTypes.ts | 2 + src/config/Config.ts | 29 +++++ src/config/backboardConfig.ts | 4 + src/config/flags.ts | 7 ++ src/config/timer.ts | 25 ++++ src/core/agent/AgentController.ts | 1 + src/core/agent/AgentLoop.ts | 18 ++- src/core/agent/AgentLoopFactory.ts | 2 + src/core/agent/ToolRoundProcessor.ts | 8 +- src/core/agent/timing/TodoSchedule.ts | 88 +++++++++++++ src/core/agent/timing/TurnTimer.ts | 38 ++++++ src/core/agent/timing/TurnTiming.ts | 46 +++++++ src/core/agent/timing/constants.ts | 3 + src/core/bus/events.ts | 1 + src/core/session/TimerContext.ts | 28 +++++ src/core/todos/TodoList.ts | 33 +++-- src/core/todos/types.ts | 1 + src/core/tools/ToolInvocationRunner.ts | 1 + src/core/tools/ToolResult.ts | 2 + src/core/tools/ToolScheduler.ts | 1 + src/entrypoints/cli.tsx | 2 + src/prompts/timer.constants.ts | 2 + src/prompts/timerPrompt.ts | 57 +++++++++ src/providers/backboard/threads.ts | 8 +- src/tools/ExecuteTool.tsx | 11 +- src/tools/TodoWriteTool.tsx | 10 ++ src/ui/App.tsx | 27 ++++ src/ui/commands/index.ts | 23 ++++ tests/AgentLoop.smoke.test.ts | 21 ++++ tests/AgentLoop.timer.test.ts | 106 ++++++++++++++++ tests/ExecuteToolCapture.test.ts | 1 + tests/Session.test.ts | 32 +++++ tests/Threads.test.ts | 27 ++++ tests/TimerConfig.test.ts | 102 +++++++++++++++ tests/TimerContext.test.ts | 31 +++++ tests/TodoSchedule.test.ts | 164 +++++++++++++++++++++++++ tests/TodoWriteTool.test.ts | 50 ++++++++ tests/TurnTimer.test.ts | 68 ++++++++++ tests/TurnTiming.test.ts | 86 +++++++++++++ 40 files changed, 1180 insertions(+), 19 deletions(-) create mode 100644 src/config/timer.ts create mode 100644 src/core/agent/timing/TodoSchedule.ts create mode 100644 src/core/agent/timing/TurnTimer.ts create mode 100644 src/core/agent/timing/TurnTiming.ts create mode 100644 src/core/agent/timing/constants.ts create mode 100644 src/core/session/TimerContext.ts create mode 100644 src/prompts/timer.constants.ts create mode 100644 src/prompts/timerPrompt.ts create mode 100644 tests/AgentLoop.timer.test.ts create mode 100644 tests/TimerConfig.test.ts create mode 100644 tests/TimerContext.test.ts create mode 100644 tests/TodoSchedule.test.ts create mode 100644 tests/TurnTimer.test.ts create mode 100644 tests/TurnTiming.test.ts diff --git a/README.md b/README.md index dadb9ad..fb8d3de 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,39 @@ For the complete first-session walkthrough, see the Backboard SSO into a separate application, see the [Backboard SSO integration guide](https://docs.backboard.io/concepts/sso). +## Per-turn time budgets + +```sh +backboard --timer 900 +backboard --timer 900 --print "Implement the fix and run the tests" +backboard --no-timer +``` + +`--timer` sets an advisory wall-clock budget in positive whole seconds. It +restarts for each turn and never cancels work. Use an external harness if you +need a hard deadline. It is separate from Execute timeouts and sub-agent limits. +The default is off. + +In an interactive session, `/timer 900` sets and saves the budget for subsequent +turns. Bare `/timer` clears it. `--timer off`, `--timer 0`, and `--no-timer` +override a saved preference for the current run without changing it on disk. + +The model receives the budget before it starts working, then remaining-time +reminders at 50%, 25%, and 10%. Reminders are appended to the last tool response +of the next completed round, not sent as asynchronous interruptions. Background +Execute launches also receive a current-time reminder. + +When the agent includes `timeBudgetSeconds` on its TodoWrite steps, step +transitions report planned versus actual duration, completed-step count, and +remaining time versus unfinished allocations. A materially overcommitted plan +is flagged once per turn. These are factual reports, not instructions to abandon +work or automatic changes to the plan. + +Timing state lives in `src/core/agent/timing/`; wording lives in +`src/prompts/timerPrompt.ts`. The harness decorates outbound tool-result copies +after recording the ordinary results, keeping injected notices out of the +local transcript. No supervisor-model calls are made. + ## Use your own model providers A Backboard login is not required when you want to call a model provider diff --git a/src/config/BackboardConfigTypes.ts b/src/config/BackboardConfigTypes.ts index 39d33ed..3f96129 100644 --- a/src/config/BackboardConfigTypes.ts +++ b/src/config/BackboardConfigTypes.ts @@ -14,6 +14,8 @@ export interface BackboardConfigFile { memoryProfile?: MemoryProfile; notify?: boolean; verbose?: boolean; + /** Advisory per-turn wall-clock budget, in seconds. */ + timerSeconds?: number; /** User-defined HTTP model providers. Secrets remain in keys.json. */ providers?: CustomProviderDefinition[]; /** Expert mode: implementation runs on `model`, planning stays on `/model`. */ diff --git a/src/config/Config.ts b/src/config/Config.ts index 39c06c1..1780860 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -41,6 +41,7 @@ import { qUserMcpConfigPath, } from "./paths.ts"; import { getProfile, type Profile } from "./profiles/index.ts"; +import { isTimerSeconds, parseTimerSeconds } from "./timer.ts"; export interface ConfigOptions { env?: BackboardEnv; @@ -85,6 +86,7 @@ export class Config { private skillDiscoveryEnabled = false; private notifyEnabled = false; private verboseEnabled = true; + private currentTimerSeconds: number | undefined; private readonly currentMemoryProfile: MemoryProfile; private currentThinking: ThinkingIntent | null | undefined; private expertEnabled = false; @@ -177,6 +179,10 @@ export class Config { } this.notifyEnabled = persistedConfig.notify ?? false; this.verboseEnabled = persistedConfig.verbose ?? true; + this.currentTimerSeconds = parseTimerSeconds( + this.flags.timer, + persistedConfig.timerSeconds, + ); this.excludedToolNames = parseExcludedTools(this.flags.excludedTools).map( canonicalToolName, ); @@ -315,6 +321,29 @@ export class Config { this.verboseEnabled = enabled; } + get timerSeconds(): number | undefined { + return this.currentTimerSeconds; + } + + setTimerSeconds(seconds: number | undefined): void { + if (seconds !== undefined && !isTimerSeconds(seconds)) { + throw new Error( + "Time budget must be a positive whole number of seconds.", + ); + } + this.currentTimerSeconds = seconds; + } + + async saveTimerPreference(): Promise { + await this.enqueueSave(async () => { + const existing = readBackboardConfig(this.persistedConfigHomeDir); + await saveBackboardConfig( + { ...existing, timerSeconds: this.currentTimerSeconds }, + this.persistedConfigHomeDir, + ); + }); + } + get memoryProfile(): MemoryProfile { return this.currentMemoryProfile; } diff --git a/src/config/backboardConfig.ts b/src/config/backboardConfig.ts index ebc8e55..47c66ae 100644 --- a/src/config/backboardConfig.ts +++ b/src/config/backboardConfig.ts @@ -15,6 +15,7 @@ import { type ThinkingLevel, } from "./defaults.ts"; import { parseCustomProviders } from "./providers.ts"; +import { isTimerSeconds } from "./timer.ts"; export type { BackboardConfigFile } from "./BackboardConfigTypes.ts"; @@ -51,6 +52,9 @@ export function readBackboardConfig( memoryProfile: readMemoryProfileConfig(config), notify: typeof config.notify === "boolean" ? config.notify : undefined, verbose: typeof config.verbose === "boolean" ? config.verbose : undefined, + timerSeconds: isTimerSeconds(config.timerSeconds) + ? config.timerSeconds + : undefined, providers: parseCustomProviders(config.providers), expert: readExpertConfig(config), }; diff --git a/src/config/flags.ts b/src/config/flags.ts index ea058f8..8356c60 100644 --- a/src/config/flags.ts +++ b/src/config/flags.ts @@ -2,6 +2,7 @@ export interface CliFlags { model?: string; format?: string; thinking?: string; + timer?: string; memory?: string; memoryProfile?: string; excludedTools: string[]; @@ -77,6 +78,12 @@ export function parseFlags(argv: string[]): CliFlags { case "thinking": flags.thinking = readValue(); break; + case "timer": + flags.timer = readValue() ?? ""; + break; + case "no-timer": + flags.timer = "off"; + break; case "memory": flags.memory = readValue(); break; diff --git a/src/config/timer.ts b/src/config/timer.ts new file mode 100644 index 0000000..2cf4f46 --- /dev/null +++ b/src/config/timer.ts @@ -0,0 +1,25 @@ +/** Shared validation for CLI flags, saved preferences, and todo allocations. */ +export function isTimerSeconds(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isSafeInteger(value) && + value > 0 && + value <= Math.floor(Number.MAX_SAFE_INTEGER / 1000) + ); +} + +export function parseTimerSeconds( + flag: string | undefined, + persisted?: number, +): number | undefined { + if (flag === undefined) return persisted; + const value = flag.trim().toLowerCase(); + if (value === "off" || value === "0") return undefined; + const seconds = Number(value); + if (!isTimerSeconds(seconds)) { + throw new Error( + '--timer must be a positive whole number of seconds (or "off").', + ); + } + return seconds; +} diff --git a/src/core/agent/AgentController.ts b/src/core/agent/AgentController.ts index 78f5a7d..09c0c0b 100644 --- a/src/core/agent/AgentController.ts +++ b/src/core/agent/AgentController.ts @@ -490,6 +490,7 @@ export class AgentController { thinkingResolver, requestKind: "user", finalVerificationNudge: config.finalVerificationNudge, + timerSeconds: config.timerSeconds, turnId: turn.id, turnStartedAt: turn.startedAt, turnAlreadyStarted: true, diff --git a/src/core/agent/AgentLoop.ts b/src/core/agent/AgentLoop.ts index 46798d1..329480d 100644 --- a/src/core/agent/AgentLoop.ts +++ b/src/core/agent/AgentLoop.ts @@ -5,6 +5,7 @@ import type { ThinkingRequestKind, } from "../../config/defaults.ts"; import type { RuntimeThinkingResolver } from "../../config/thinkingRuntime.ts"; +import { timerBudgetPrompt } from "../../prompts/timerPrompt.ts"; import type { AgentClient, RunMessageOptions, @@ -25,6 +26,7 @@ import { ProviderStreamConsumer } from "./ProviderStreamConsumer.ts"; import { buildRunMessageRequest } from "./RunMessageRequestBuilder.ts"; import { ToolRoundProcessor } from "./ToolRoundProcessor.ts"; import { Turn } from "./Turn.ts"; +import { TurnTiming } from "./timing/TurnTiming.ts"; export interface AgentLoopDeps { client: AgentClient; @@ -44,6 +46,8 @@ export interface AgentLoopDeps { thinkingResolver?: RuntimeThinkingResolver; requestKind?: ThinkingRequestKind; finalVerificationNudge?: boolean; + /** Advisory only; independent of sub-agent execution limits. */ + timerSeconds?: number; turnId?: string; turnStartedAt?: number; turnAlreadyStarted?: boolean; @@ -78,6 +82,14 @@ export class AgentLoop { } const consumer = new ProviderStreamConsumer(bus, session); + const timing = + this.deps.timerSeconds === undefined + ? undefined + : new TurnTiming(this.deps.timerSeconds, turn.startedAt); + const timedContent = + this.deps.timerSeconds === undefined + ? content + : `${timerBudgetPrompt(this.deps.timerSeconds)}\n\n${content}`; const processor = new ToolRoundProcessor({ client: this.deps.client, scheduler: this.deps.scheduler, @@ -86,6 +98,7 @@ export class AgentLoop { consumer, tools: this.deps.tools, maxToolRounds: this.deps.maxToolRounds, + timing, }); this.executedToolRounds = 0; @@ -133,11 +146,12 @@ export class AgentLoop { const messageOptions = { signal: ctx.signal, attachmentFilePaths: this.deps.attachmentFilePaths, - displayContent: this.deps.displayContent, + displayContent: + this.deps.displayContent ?? (timing ? content : undefined), durableSession: this.deps.durableSession, }; const buildMessageRequest = () => - buildRunMessageRequest(content, this.deps, initialThinking); + buildRunMessageRequest(timedContent, this.deps, initialThinking); const createMessageStream = () => this.deps.client.runMessage(buildMessageRequest(), messageOptions); diff --git a/src/core/agent/AgentLoopFactory.ts b/src/core/agent/AgentLoopFactory.ts index fbb5478..5ee181f 100644 --- a/src/core/agent/AgentLoopFactory.ts +++ b/src/core/agent/AgentLoopFactory.ts @@ -47,6 +47,7 @@ export interface CreateLoopOptions { thinkingResolver?: RuntimeThinkingResolver; requestKind: ThinkingRequestKind; finalVerificationNudge?: boolean; + timerSeconds?: number; turnId?: string; turnStartedAt?: number; turnAlreadyStarted?: boolean; @@ -106,6 +107,7 @@ export class AgentLoopFactory { thinkingResolver: options.thinkingResolver, requestKind: options.requestKind, finalVerificationNudge: options.finalVerificationNudge, + timerSeconds: options.timerSeconds, turnId: options.turnId, turnStartedAt: options.turnStartedAt, turnAlreadyStarted: options.turnAlreadyStarted, diff --git a/src/core/agent/ToolRoundProcessor.ts b/src/core/agent/ToolRoundProcessor.ts index 059eac9..9c96a9b 100644 --- a/src/core/agent/ToolRoundProcessor.ts +++ b/src/core/agent/ToolRoundProcessor.ts @@ -17,6 +17,7 @@ import type { ProviderStreamConsumer, } from "./ProviderStreamConsumer.ts"; import { preserveProviderContext } from "./ProviderStreamConsumer.ts"; +import type { TurnTiming } from "./timing/TurnTiming.ts"; export interface ToolRoundProcessorDeps { client: AgentClient; @@ -26,6 +27,7 @@ export interface ToolRoundProcessorDeps { consumer: ProviderStreamConsumer; tools: SubmitToolOutputsRequest["tools"]; maxToolRounds?: number; + timing?: TurnTiming; } export class ToolRoundProcessor { @@ -123,10 +125,14 @@ export class ToolRoundProcessor { } this.recordToolMessage(newCalls, outputs); + // Decorate only the outbound copies, after recording ordinary results. + // Interrupted rounds above never consume timer thresholds. const request: SubmitToolOutputsRequest = { thread_id: this.deps.session.threadId ?? "", ...(pending.runId ? { run_id: pending.runId } : {}), - tool_outputs: outputs.map(toBackboardToolOutput), + tool_outputs: + this.deps.timing?.append(outputs, this.deps.session.todos) ?? + outputs.map(toBackboardToolOutput), tools: this.deps.tools, }; round = this.createEarlyRound(ctx, turnId); diff --git a/src/core/agent/timing/TodoSchedule.ts b/src/core/agent/timing/TodoSchedule.ts new file mode 100644 index 0000000..0a7bfa5 --- /dev/null +++ b/src/core/agent/timing/TodoSchedule.ts @@ -0,0 +1,88 @@ +import { + timerReminder, + todoOvercommitNotice, + todoProgressNotice, + todoStepNotice, +} from "../../../prompts/timerPrompt.ts"; +import type { TodoItem } from "../../bus/events.ts"; +import { MIN_PLAN_SLACK_MS, PLAN_SLACK_FRACTION } from "./constants.ts"; +import type { TurnTimer } from "./TurnTimer.ts"; + +/** Factual progress against the agent's own allocations, not scheduling policy. */ +export class TodoSchedule { + private activeId?: string; + private activeStartedAt?: number; + private activeBudgetMs?: number; + private overcommitReported = false; + + private forgetActive(): void { + this.activeId = undefined; + this.activeStartedAt = undefined; + this.activeBudgetMs = undefined; + } + + onUpdate( + todos: readonly TodoItem[], + timer: TurnTimer | undefined, + now = Date.now(), + ): string | null { + if (!timer || todos.length === 0) { + this.forgetActive(); + return null; + } + const active = todos.find((todo) => todo.status === "in_progress"); + const changed = this.activeId !== undefined && active?.id !== this.activeId; + const previous = changed + ? todos.find((todo) => todo.id === this.activeId) + : undefined; + const tookMs = + changed && this.activeStartedAt !== undefined + ? now - this.activeStartedAt + : undefined; + const plannedMs = changed ? this.activeBudgetMs : undefined; + + // Re-arm even for unbudgeted replans, so a later report cannot span + // abandoned steps. Preserve the original allowance while a step runs. + if (changed) this.forgetActive(); + if (active && this.activeId === undefined) { + this.activeId = active.id; + this.activeStartedAt = now; + this.activeBudgetMs = + active.timeBudgetSeconds === undefined + ? undefined + : active.timeBudgetSeconds * 1000; + } + if (!todos.some((todo) => todo.timeBudgetSeconds !== undefined)) + return null; + + const remainingMs = timer.remainingMs(now); + const aheadMs = todos + .filter((todo) => todo.status !== "completed") + .reduce((sum, todo) => sum + (todo.timeBudgetSeconds ?? 0) * 1000, 0); + const lines: string[] = []; + const slackMs = Math.max( + MIN_PLAN_SLACK_MS, + timer.totalBudgetMs * PLAN_SLACK_FRACTION, + ); + if (!this.overcommitReported && aheadMs > remainingMs + slackMs) { + this.overcommitReported = true; + lines.push(todoOvercommitNotice(aheadMs, remainingMs)); + } + if (tookMs !== undefined) { + lines.push( + todoStepNotice(previous?.status === "completed", tookMs, plannedMs), + ); + } + if (lines.length === 0) return null; + lines.push( + todoProgressNotice( + todos.filter((todo) => todo.status === "completed").length, + todos.length, + remainingMs, + timer.totalBudgetMs, + aheadMs, + ), + ); + return timerReminder(lines); + } +} diff --git a/src/core/agent/timing/TurnTimer.ts b/src/core/agent/timing/TurnTimer.ts new file mode 100644 index 0000000..3273b6d --- /dev/null +++ b/src/core/agent/timing/TurnTimer.ts @@ -0,0 +1,38 @@ +import { timerNotice } from "../../../prompts/timerPrompt.ts"; +import { TIMER_THRESHOLDS } from "./constants.ts"; + +/** Advisory wall-clock budget. One instance per turn; never cancels work. */ +export class TurnTimer { + private readonly fired = new Set(); + + constructor( + readonly totalBudgetMs: number, + private readonly startedAt: number, + ) {} + + remainingMs(now = Date.now()): number { + return Math.max(0, this.startedAt + this.totalBudgetMs - now); + } + + nextReminder(now = Date.now()): string | null { + if (this.totalBudgetMs <= 0) return null; + const fraction = this.remainingMs(now) / this.totalBudgetMs; + // A slow round can cross several thresholds. Report only the latest. + const crossed = TIMER_THRESHOLDS.findLast( + (threshold) => fraction <= threshold, + ); + if (crossed === undefined || this.fired.has(crossed)) return null; + return this.reportNow(now); + } + + /** A running command has just returned control; waiting needs a fresh clock. */ + reportNow(now = Date.now()): string | null { + if (this.totalBudgetMs <= 0) return null; + const remaining = this.remainingMs(now); + for (const threshold of TIMER_THRESHOLDS) { + if (remaining / this.totalBudgetMs <= threshold) + this.fired.add(threshold); + } + return timerNotice(remaining, this.totalBudgetMs); + } +} diff --git a/src/core/agent/timing/TurnTiming.ts b/src/core/agent/timing/TurnTiming.ts new file mode 100644 index 0000000..0a79f7d --- /dev/null +++ b/src/core/agent/timing/TurnTiming.ts @@ -0,0 +1,46 @@ +import type { SubmitToolOutputsRequest } from "../../../providers/backboard/types.ts"; +import type { TodoItem } from "../../bus/events.ts"; +import { canonicalToolName } from "../../tools/names.ts"; +import type { ToolOutput } from "../../tools/ToolScheduler.ts"; +import { TodoSchedule } from "./TodoSchedule.ts"; +import { TurnTimer } from "./TurnTimer.ts"; + +/** Decorates outbound copies only. Local results and error prefixes stay intact. */ +export class TurnTiming { + private readonly timer: TurnTimer; + private readonly schedule = new TodoSchedule(); + + constructor(seconds: number, startedAt: number) { + this.timer = new TurnTimer(seconds * 1000, startedAt); + } + + append( + outputs: readonly ToolOutput[], + todos: readonly TodoItem[], + now = Date.now(), + ): SubmitToolOutputsRequest["tool_outputs"] { + const wire = outputs.map(({ tool_call_id, output }) => ({ + tool_call_id, + output, + })); + const last = wire.at(-1); + if (!last) return wire; + const timerNote = outputs.some((output) => output.metadata?.stillRunning) + ? this.timer.reportNow(now) + : this.timer.nextReminder(now); + const todoUpdated = outputs.some( + (output) => + output.metadata && + !output.metadata.error && + canonicalToolName(output.metadata.name) === + canonicalToolName("TodoWrite"), + ); + const todoNote = todoUpdated + ? this.schedule.onUpdate(todos, this.timer, now) + : null; + for (const note of [timerNote, todoNote]) { + if (note) last.output += `\n\n${note}`; + } + return wire; + } +} diff --git a/src/core/agent/timing/constants.ts b/src/core/agent/timing/constants.ts new file mode 100644 index 0000000..77883ae --- /dev/null +++ b/src/core/agent/timing/constants.ts @@ -0,0 +1,3 @@ +export const TIMER_THRESHOLDS = [0.5, 0.25, 0.1] as const; +export const MIN_PLAN_SLACK_MS = 60_000; +export const PLAN_SLACK_FRACTION = 0.1; diff --git a/src/core/bus/events.ts b/src/core/bus/events.ts index 90ef3d4..b1561b1 100644 --- a/src/core/bus/events.ts +++ b/src/core/bus/events.ts @@ -75,6 +75,7 @@ export interface TodoItem { id: string; content: string; status: "pending" | "in_progress" | "completed"; + timeBudgetSeconds?: number; } export interface AskUserQuestionSpec { diff --git a/src/core/session/TimerContext.ts b/src/core/session/TimerContext.ts new file mode 100644 index 0000000..0a0f820 --- /dev/null +++ b/src/core/session/TimerContext.ts @@ -0,0 +1,28 @@ +import { + TIMER_REMINDER_CLOSE, + TIMER_REMINDER_OPEN, +} from "../../prompts/timer.constants.ts"; + +/** Remove only our tagged leading budget, never a general system reminder. */ +export function withoutTimerPrefix(content: string): string { + if (!content.startsWith(`${TIMER_REMINDER_OPEN}\n`)) return content; + const end = content.indexOf(`\n${TIMER_REMINDER_CLOSE}\n\n`); + return end < 0 + ? content + : content.slice(end + TIMER_REMINDER_CLOSE.length + 3); +} + +/** Provider history retains model context; resume rendering drops our suffixes. */ +export function withoutTimerSuffix(content: string): string { + const open = `\n\n${TIMER_REMINDER_OPEN}\n`; + const close = `\n${TIMER_REMINDER_CLOSE}`; + // A round appends at most one clock notice and one todo notice. + for (let i = 0; i < 2 && content.endsWith(close); i++) { + const start = content.lastIndexOf(open); + if (start < 0) break; + const body = content.slice(start + open.length, -close.length); + if (body.includes(TIMER_REMINDER_CLOSE)) break; + content = content.slice(0, start); + } + return content; +} diff --git a/src/core/todos/TodoList.ts b/src/core/todos/TodoList.ts index c54b2cd..076b4d8 100644 --- a/src/core/todos/TodoList.ts +++ b/src/core/todos/TodoList.ts @@ -1,3 +1,4 @@ +import { isTimerSeconds } from "../../config/timer.ts"; import { shortId } from "../../utils/id.ts"; import type { TodoItem } from "../bus/events.ts"; import type { Message } from "../session/Message.ts"; @@ -66,10 +67,13 @@ export function reconcileTodos( return drafts.map((draft) => { const previous = previousByContent.get(draft.content)?.shift(); + const timeBudgetSeconds = + draft.timeBudgetSeconds ?? previous?.timeBudgetSeconds; return { id: previous?.id ?? shortId("todo"), content: draft.content, status: draft.status, + ...(timeBudgetSeconds === undefined ? {} : { timeBudgetSeconds }), }; }); } @@ -79,19 +83,21 @@ export function areTodosComplete(todos: readonly TodoItem[]): boolean { } export function todosFromMessages(messages: readonly Message[]): TodoItem[] { - for (let i = messages.length - 1; i >= 0; i--) { + let todos: TodoItem[] = []; + for (let i = 0; i < messages.length; i++) { const message = messages[i]; + if (message?.role === "user" && areTodosComplete(todos)) todos = []; if (message?.role !== "assistant") continue; - for (let j = message.toolCalls.length - 1; j >= 0; j--) { - const call = message.toolCalls[j]; - if (!call || canonicalToolName(call.name) !== "todo_write") continue; + for (const call of message.toolCalls) { + if (canonicalToolName(call.name) !== "todo_write") continue; const matchingResult = findToolResult(messages, i + 1, call.id); if (!matchingResult || matchingResult.isError) continue; - const todos = todoItemsFromInput(call.input); - return areTodosComplete(todos) ? [] : todos; + // Replay successful updates so status-only writes retain allocations. + // Keep historical statuses as recorded, including partial transcripts. + todos = reconcileTodos(todoDraftsFromInput(call.input), todos); } } - return []; + return areTodosComplete(todos) ? [] : todos; } function findToolResult( @@ -110,16 +116,17 @@ function findToolResult( return undefined; } -function todoItemsFromInput(input: unknown): TodoItem[] { +function todoDraftsFromInput(input: unknown): TodoDraft[] { if (!input || typeof input !== "object") return []; const drafts = (input as { todos?: unknown }).todos; if (!Array.isArray(drafts)) return []; - const todos: TodoItem[] = []; + const todos: TodoDraft[] = []; for (const draft of drafts) { if (!draft || typeof draft !== "object") continue; - const { content, status } = draft as { + const { content, status, timeBudgetSeconds } = draft as { content?: unknown; status?: unknown; + timeBudgetSeconds?: unknown; }; if (typeof content !== "string" || content.length === 0) continue; if ( @@ -128,7 +135,11 @@ function todoItemsFromInput(input: unknown): TodoItem[] { status !== "completed" ) continue; - todos.push({ id: shortId("todo"), content, status }); + todos.push({ + content, + status, + ...(isTimerSeconds(timeBudgetSeconds) ? { timeBudgetSeconds } : {}), + }); } return normalizeTodoDrafts(todos); } diff --git a/src/core/todos/types.ts b/src/core/todos/types.ts index 8074330..a306fc5 100644 --- a/src/core/todos/types.ts +++ b/src/core/todos/types.ts @@ -3,4 +3,5 @@ import type { TodoItem } from "../bus/events.ts"; export interface TodoDraft { content: string; status: TodoItem["status"]; + timeBudgetSeconds?: number; } diff --git a/src/core/tools/ToolInvocationRunner.ts b/src/core/tools/ToolInvocationRunner.ts index 370f48c..7c564b4 100644 --- a/src/core/tools/ToolInvocationRunner.ts +++ b/src/core/tools/ToolInvocationRunner.ts @@ -143,6 +143,7 @@ export class ToolInvocationRunner { name: ref.name, readOnly: entry.tool.isReadOnly(hookInput), error: false, + ...(result.stillRunning ? { stillRunning: true } : {}), }, }; } catch (err) { diff --git a/src/core/tools/ToolResult.ts b/src/core/tools/ToolResult.ts index a795c59..0637c6f 100644 --- a/src/core/tools/ToolResult.ts +++ b/src/core/tools/ToolResult.ts @@ -12,6 +12,8 @@ export interface ToolResult { title: string; detail?: string; detailLines?: ToolResultDetailLine[]; + /** The tool returned control while its work continues in the background. */ + stillRunning?: boolean; } export function ok( diff --git a/src/core/tools/ToolScheduler.ts b/src/core/tools/ToolScheduler.ts index 893294e..0c87064 100644 --- a/src/core/tools/ToolScheduler.ts +++ b/src/core/tools/ToolScheduler.ts @@ -26,6 +26,7 @@ export interface ToolOutputMetadata { name: string; readOnly: boolean; error: boolean; + stillRunning?: boolean; } /** diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index b6b6762..1b6e37f 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -107,6 +107,8 @@ Options: --model Model to use (e.g. openai/gpt-5.5) --format Output format (default: default) --thinking Thinking: off, low, medium, high, max, or token budget + --timer Advisory per-turn time budget with todo-step feedback + --no-timer Ignore a saved time budget for this run --memory Memory mode: off, on, auto, readonly --memory-profile Memory profile: default, code, coding --excluded-tools Comma-separated tool names to hide from the agent diff --git a/src/prompts/timer.constants.ts b/src/prompts/timer.constants.ts new file mode 100644 index 0000000..55d1c66 --- /dev/null +++ b/src/prompts/timer.constants.ts @@ -0,0 +1,2 @@ +export const TIMER_REMINDER_OPEN = ''; +export const TIMER_REMINDER_CLOSE = ""; diff --git a/src/prompts/timerPrompt.ts b/src/prompts/timerPrompt.ts new file mode 100644 index 0000000..c24325f --- /dev/null +++ b/src/prompts/timerPrompt.ts @@ -0,0 +1,57 @@ +import { + TIMER_REMINDER_CLOSE, + TIMER_REMINDER_OPEN, +} from "./timer.constants.ts"; + +export function formatTimerDuration(ms: number): string { + const seconds = Math.max(0, Math.floor(ms / 1000)); + return seconds < 120 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`; +} + +export function timerReminder(lines: readonly string[]): string { + return `${TIMER_REMINDER_OPEN}\n${lines.join(" ")}\n${TIMER_REMINDER_CLOSE}`; +} + +/** Per-turn context belongs on the message, not the reusable system prefix. */ +export function timerBudgetPrompt(seconds: number): string { + return timerReminder([ + `## Time budget\n\nYou are allocated ${formatTimerDuration(seconds * 1000)} for this turn. Plan and allocate your time accordingly to fully complete the task end to end.`, + "The CLI reports this budget but does not stop execution; an external harness may enforce a hard deadline.", + "When you plan with TodoWrite, give each step a `timeBudgetSeconds`. They should add up to no more than the time still remaining when you write the plan, which is less than the full budget.", + "Brief notices will report the time remaining and how each step went against your own plan; they need no reply.", + ]); +} + +export function timerNotice(remainingMs: number, totalMs: number): string { + return timerReminder([ + `Time check: ${formatTimerDuration(remainingMs)} of the ${formatTimerDuration(totalMs)} budget remains. No reply needed.`, + ]); +} + +export function todoOvercommitNotice( + plannedMs: number, + remainingMs: number, +): string { + return `Your steps allot ${formatTimerDuration(plannedMs)} but ${formatTimerDuration(remainingMs)} remains.`; +} + +export function todoStepNotice( + finished: boolean, + tookMs: number, + plannedMs: number | undefined, +): string { + const subject = finished ? "Previous step" : "The step you moved off"; + return plannedMs === undefined + ? `${subject} took ${formatTimerDuration(tookMs)}.` + : `${subject}: planned ${formatTimerDuration(plannedMs)}, took ${formatTimerDuration(tookMs)}.`; +} + +export function todoProgressNotice( + done: number, + count: number, + remainingMs: number, + totalMs: number, + aheadMs: number, +): string { + return `${done}/${count} steps done. ${formatTimerDuration(remainingMs)} of the ${formatTimerDuration(totalMs)} budget remains; remaining steps are allotted ${formatTimerDuration(aheadMs)}. No reply needed.`; +} diff --git a/src/providers/backboard/threads.ts b/src/providers/backboard/threads.ts index 8fd43b1..4ee2ddb 100644 --- a/src/providers/backboard/threads.ts +++ b/src/providers/backboard/threads.ts @@ -5,6 +5,10 @@ import { toolMessage, userMessage, } from "../../core/session/Message.ts"; +import { + withoutTimerPrefix, + withoutTimerSuffix, +} from "../../core/session/TimerContext.ts"; import { FINAL_VERIFICATION_NUDGE } from "../../prompts/finalVerification.ts"; import { PLAN_UP_TO_DATE_REPLY } from "../../prompts/todoReminders.ts"; import { truncate } from "../../utils/string.ts"; @@ -98,7 +102,7 @@ function backboardMessageToSessionMessage( // Injected system notifications persist server-side as ordinary user // messages; drop them on resume so they don't render as human input. if (isInjectedNotificationMessage(message, content)) return null; - return userMessage(content); + return userMessage(withoutTimerPrefix(content)); case "assistant": { const toolCalls = toolCallsFromMetadata(message.metadata_); // The hidden reconciliation reply was never shown; keep it that way. @@ -117,7 +121,7 @@ function backboardMessageToSessionMessage( { toolCallId: stringMetadata(message.metadata_, "tool_call_id") ?? "", name: stringMetadata(message.metadata_, "tool_name") ?? "Tool", - output: content, + output: withoutTimerSuffix(content), isError: message.status === "FAILED", }, ]); diff --git a/src/tools/ExecuteTool.tsx b/src/tools/ExecuteTool.tsx index 771421d..63814fb 100644 --- a/src/tools/ExecuteTool.tsx +++ b/src/tools/ExecuteTool.tsx @@ -275,9 +275,14 @@ export class ExecuteTool extends Tool { if (pid === undefined) throw new Error("background command did not start"); const output = `started background command\npid: ${pid}\nlog path: ${logPath}`; - return Promise.resolve( - ok({ pid, logPath, fireAndForget: true }, output, `Started PID ${pid}`), - ); + return Promise.resolve({ + ...ok( + { pid, logPath, fireAndForget: true }, + output, + `Started PID ${pid}`, + ), + stillRunning: true, + }); } finally { closeSync(stdoutFd); closeSync(stderrFd); diff --git a/src/tools/TodoWriteTool.tsx b/src/tools/TodoWriteTool.tsx index 463f38f..c33b856 100644 --- a/src/tools/TodoWriteTool.tsx +++ b/src/tools/TodoWriteTool.tsx @@ -1,4 +1,5 @@ import { z } from "zod"; +import { isTimerSeconds } from "../config/timer.ts"; import type { TodoItem } from "../core/bus/events.ts"; import type { PermissionDecision } from "../core/permissions/types.ts"; import { normalizeTodoUpdate, reconcileTodos } from "../core/todos/TodoList.ts"; @@ -18,6 +19,15 @@ const todoSchema = z.object({ status: z .enum(["pending", "in_progress", "completed"]) .describe("One of pending, in_progress, or completed."), + timeBudgetSeconds: z + .number() + .int() + .min(1) + .refine(isTimerSeconds, "Time budget is too large.") + .optional() + .describe( + "Seconds you plan to spend on this step when a turn time budget is set. Allocate no more than the remaining turn budget across unfinished steps.", + ), }); const schema = z.object({ diff --git a/src/ui/App.tsx b/src/ui/App.tsx index e51d54f..a41755a 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1701,6 +1701,33 @@ export function App({ ); break; } + case "timer": { + if (command.error) { + agent.notice(command.error, "error"); + break; + } + if (command.seconds === null && config.timerSeconds === undefined) { + agent.notice( + "No time budget set. Use /timer to set one.", + "warning", + ); + break; + } + const next = command.seconds ?? undefined; + config.setTimerSeconds(next); + void config.saveTimerPreference().catch((err) => { + agent.notice( + `Failed to save timer preference: ${errorMessage(err)}`, + "error", + ); + }); + agent.notice( + next === undefined + ? "Time budget cleared for subsequent turns." + : `Time budget set to ${next}s per turn, starting with the next turn.`, + ); + break; + } case "update": agent.notice("Checking for updates…"); void checkForCliUpdate({ diff --git a/src/ui/commands/index.ts b/src/ui/commands/index.ts index d4c9a84..e39e816 100644 --- a/src/ui/commands/index.ts +++ b/src/ui/commands/index.ts @@ -1,4 +1,5 @@ import { APP_DISPLAY_NAME } from "../../config/branding.ts"; +import { parseTimerSeconds } from "../../config/timer.ts"; export type Command = | { type: "message"; text: string } @@ -23,6 +24,7 @@ export type Command = | { type: "sessions"; id?: string } | { type: "notify" } | { type: "verbose" } + | { type: "timer"; seconds: number | null; error?: string } | { type: "update" } | { type: "undo" } | { type: "redo" } @@ -145,6 +147,11 @@ export const SLASH_COMMANDS: readonly SlashCommandDefinition[] = [ type: "update", description: "Check for a newer CLI version", }, + { + name: "timer", + type: "timer", + description: "Set a per-turn time budget in seconds (/timer to clear)", + }, { name: "undo", type: "undo", @@ -194,6 +201,21 @@ export function parseCommand(input: string): Command { const name = rawName.toLowerCase(); const definition = findSlashCommand(name); if (definition) { + if (definition.type === "timer") { + const value = rawArgs.join(" "); + try { + return { + type: "timer", + seconds: value ? (parseTimerSeconds(value) ?? null) : null, + }; + } catch { + return { + type: "timer", + seconds: null, + error: "Use /timer , or /timer to clear.", + }; + } + } if (definition.type === "sessions") { const id = rawArgs.join(" ").trim(); return id ? { type: "sessions", id } : { type: "sessions" }; @@ -238,6 +260,7 @@ export function canRunCommandAfterSessionEnd( command === "lsp" || command === "notify" || command === "verbose" || + command === "timer" || command === "update" || command === "undo" || command === "redo" || diff --git a/tests/AgentLoop.smoke.test.ts b/tests/AgentLoop.smoke.test.ts index 2abc4f0..33daf3f 100644 --- a/tests/AgentLoop.smoke.test.ts +++ b/tests/AgentLoop.smoke.test.ts @@ -222,6 +222,27 @@ function controllerWith( } describe("AgentController loop (mocked Backboard)", () => { + it("passes the configured timer through the loop factory without changing the system prefix", async () => { + const client = new FakeClient( + [{ kind: "thread", threadId: "thr_timer" }, { kind: "completed" }], + [], + ); + const { ctrl } = controllerWith(client, new TestTool({ name: "Read" }), [ + "--timer", + "900", + ]); + expect(await ctrl.submit("hello")).toBe("completed"); + expect(client.messageRequests[0]?.content).toContain( + "allocated 15m for this turn", + ); + expect(client.messageRequests[0]?.system_prompt).not.toContain( + "## Time budget", + ); + expect(client.assistantRequests[0]?.system_prompt).not.toContain( + "## Time budget", + ); + await ctrl.dispose(); + }); it("emits turn:start and accepted prompt before assistant setup finishes", async () => { const client = new FakeClient( [ diff --git a/tests/AgentLoop.timer.test.ts b/tests/AgentLoop.timer.test.ts new file mode 100644 index 0000000..8735a11 --- /dev/null +++ b/tests/AgentLoop.timer.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "bun:test"; +import { AgentLoop } from "../src/core/agent/AgentLoop.ts"; +import { EventBus } from "../src/core/bus/EventBus.ts"; +import { Session } from "../src/core/session/Session.ts"; +import { ToolRegistry } from "../src/core/tools/ToolRegistry.ts"; +import { ToolScheduler } from "../src/core/tools/ToolScheduler.ts"; +import type { + AgentClient, + RunMessageOptions, +} from "../src/providers/AgentClient.ts"; +import type { + ProviderEvent, + SendMessageRequest, + SubmitToolOutputsRequest, +} from "../src/providers/backboard/types.ts"; +import { makeContext, TestTool } from "./helpers.ts"; + +describe("AgentLoop timer integration", () => { + it("puts the budget on the message and notices on outbound results, not the transcript", async () => { + const messageRequests: SendMessageRequest[] = []; + const resultRequests: SubmitToolOutputsRequest[] = []; + const display: Array = []; + const client = { + async *runMessage( + request: SendMessageRequest, + options: RunMessageOptions, + ): AsyncIterable { + messageRequests.push(request); + display.push(options.displayContent); + yield { kind: "thread", threadId: "thread" }; + yield { + kind: "requires_action", + runId: "run", + calls: [{ id: "read", name: "Read", input: {} }], + }; + }, + async *runToolOutputs( + request: SubmitToolOutputsRequest, + ): AsyncIterable { + resultRequests.push(request); + yield { kind: "completed" }; + }, + } as unknown as AgentClient; + const bus = new EventBus(); + const session = new Session("timer-test"); + const registry = new ToolRegistry([new TestTool({ name: "Read" })]); + const loop = new AgentLoop({ + client, + bus, + session, + scheduler: new ToolScheduler(registry, bus), + tools: [], + systemPrompt: "static prefix", + model: { provider: "test", model: "test" }, + memory: "off", + memoryProfile: "default", + thinking: undefined, + timerSeconds: 900, + turnStartedAt: Date.now() - 480_000, + }); + expect( + await loop.run("Fix it", makeContext(new AbortController().signal, bus)), + ).toBe("completed"); + expect(messageRequests[0]?.content).toContain("## Time budget"); + expect(messageRequests[0]?.content).toContain("timeBudgetSeconds"); + expect(messageRequests[0]?.content).toEndWith("Fix it"); + expect(messageRequests[0]?.system_prompt).toBe("static prefix"); + expect(display).toEqual(["Fix it"]); + expect(resultRequests[0]?.tool_outputs[0]?.output).toContain("Time check:"); + expect(JSON.stringify(session.getMessages())).not.toContain("Time check:"); + }); + + it("leaves untimed messages unchanged and supplies the budget even without tools", async () => { + const messages: SendMessageRequest[] = []; + const client = { + async *runMessage( + request: SendMessageRequest, + ): AsyncIterable { + messages.push(request); + yield { kind: "completed" }; + }, + } as unknown as AgentClient; + const bus = new EventBus(); + for (const timerSeconds of [undefined, 60, undefined]) { + const loop = new AgentLoop({ + client, + bus, + session: new Session("test"), + scheduler: new ToolScheduler(new ToolRegistry([]), bus), + tools: [], + systemPrompt: "static prefix", + model: { provider: "test", model: "test" }, + memory: "off", + memoryProfile: "default", + thinking: undefined, + timerSeconds, + }); + expect( + await loop.run("hello", makeContext(new AbortController().signal, bus)), + ).toBe("completed"); + } + expect(messages[0]?.content).toBe("hello"); + expect(messages[1]?.content).toContain("allocated 60s for this turn"); + expect(messages[2]?.content).toBe("hello"); + }); +}); diff --git a/tests/ExecuteToolCapture.test.ts b/tests/ExecuteToolCapture.test.ts index e5c0334..719da5d 100644 --- a/tests/ExecuteToolCapture.test.ts +++ b/tests/ExecuteToolCapture.test.ts @@ -74,6 +74,7 @@ describe("ExecuteTool shell capture wiring", () => { ctx, ); expect(result.data.fireAndForget).toBe(true); + expect(result.stillRunning).toBe(true); expect(calls).toEqual([]); }, 10_000); }); diff --git a/tests/Session.test.ts b/tests/Session.test.ts index 2e88ce7..055f191 100644 --- a/tests/Session.test.ts +++ b/tests/Session.test.ts @@ -8,6 +8,38 @@ import { import { Session } from "../src/core/session/Session.ts"; describe("Session", () => { + it("restores inherited allocations across status-only todo updates", () => { + const session = new Session("timer-resume"); + const write = (id: string, todos: unknown[]) => [ + assistantMessage("", [{ id, name: "todo_write", input: { todos } }]), + toolMessage([ + { + toolCallId: id, + name: "todo_write", + output: "Updated todos", + isError: false, + }, + ]), + ]; + session.hydrate({ + threadId: "thread", + messages: [ + ...write("plan", [ + { content: "A", status: "in_progress", timeBudgetSeconds: 120 }, + { content: "B", status: "pending", timeBudgetSeconds: 300 }, + ]), + ...write("advance", [ + { content: "A", status: "completed" }, + { content: "B", status: "in_progress" }, + ]), + ], + }); + expect(session.todos).toMatchObject([ + { content: "A", status: "completed", timeBudgetSeconds: 120 }, + { content: "B", status: "in_progress", timeBudgetSeconds: 300 }, + ]); + }); + it("tracks todos from bus events and clears completed todos on next turn", () => { const bus = new EventBus(); const session = new Session("sess_test"); diff --git a/tests/Threads.test.ts b/tests/Threads.test.ts index f34d42e..e10807c 100644 --- a/tests/Threads.test.ts +++ b/tests/Threads.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "bun:test"; import { FINAL_VERIFICATION_NUDGE } from "../src/prompts/finalVerification.ts"; +import { + timerBudgetPrompt, + timerNotice, + timerReminder, +} from "../src/prompts/timerPrompt.ts"; import { PLAN_UP_TO_DATE_REPLY, todoReconciliationReminder, @@ -15,6 +20,28 @@ import type { } from "../src/providers/backboard/types.ts"; describe("Backboard thread helpers", () => { + it("restores human input and tool output without tagged timer context", () => { + const original = + "Error: tests failed\nkeep this real output"; + const messages = backboardThreadToMessages( + threadWithMessages([ + { role: "user", content: `${timerBudgetPrompt(900)}\n\nFix the tests` }, + { + role: "tool", + content: `${original}\n\n${timerNotice(450_000, 900_000)}\n\n${timerReminder(["Previous step: planned 3m, took 8m."])}`, + }, + { role: "tool", content: original }, + ]), + ); + expect(messages[0]).toMatchObject({ role: "user", text: "Fix the tests" }); + for (const message of messages.slice(1)) { + expect(message).toMatchObject({ + role: "tool", + results: [{ output: original }], + }); + } + }); + it("uses the latest message timestamp as the thread updated time", () => { const thread = testThread("older", "2026-06-30T10:00:00", [ "2026-06-30T10:01:00", diff --git a/tests/TimerConfig.test.ts b/tests/TimerConfig.test.ts new file mode 100644 index 0000000..99b4c27 --- /dev/null +++ b/tests/TimerConfig.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + readBackboardConfig, + saveBackboardConfig, +} from "../src/config/backboardConfig.ts"; +import { Config } from "../src/config/Config.ts"; +import { parseFlags } from "../src/config/flags.ts"; +import { parseTimerSeconds } from "../src/config/timer.ts"; +import { + canRunCommandAfterSessionEnd, + parseCommand, + slashCommandSuggestions, +} from "../src/ui/commands/index.ts"; + +const env = { apiKey: "test", apiUrl: "https://example.test/api" }; +const homes: string[] = []; +afterEach(async () => { + await Promise.all( + homes.splice(0).map((home) => rm(home, { recursive: true, force: true })), + ); +}); + +describe("timer configuration", () => { + it("supports both flag forms and explicit disable overrides", () => { + expect(new Config({ env, argv: [] }).timerSeconds).toBeUndefined(); + for (const argv of [["--timer", "900"], ["--timer=900"]]) { + expect(new Config({ env, argv }).timerSeconds).toBe(900); + } + for (const value of ["off", "OFF", "0"]) { + expect(parseTimerSeconds(value, 900)).toBeUndefined(); + } + expect(parseTimerSeconds(undefined, 900)).toBe(900); + expect(parseFlags(["--timer", "900", "--no-timer"]).timer).toBe("off"); + expect(parseFlags(["--no-timer", "--timer=60"]).timer).toBe("60"); + }); + + it("rejects missing, fractional, negative, and non-finite budgets", () => { + for (const value of [ + "", + "soon", + "-5", + "0.1", + "1.5", + "NaN", + "Infinity", + "1e300", + ]) { + expect(() => parseTimerSeconds(value)).toThrow("--timer"); + } + expect(() => new Config({ env, argv: ["--timer"] })).toThrow("--timer"); + }); + + it("persists runtime changes without letting flags overwrite preferences", async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), "timer-config-")); + homes.push(homeDir); + await saveBackboardConfig({ timerSeconds: 900, notify: true }, homeDir); + const config = new Config({ env, homeDir, argv: ["--no-timer"] }); + expect(config.timerSeconds).toBeUndefined(); + await config.saveRuntimeSelection(); + expect(readBackboardConfig(homeDir).timerSeconds).toBe(900); + config.setTimerSeconds(120); + await config.saveTimerPreference(); + expect(new Config({ env, homeDir, argv: [] }).timerSeconds).toBe(120); + expect(readBackboardConfig(homeDir).notify).toBe(true); + config.setTimerSeconds(undefined); + await config.saveTimerPreference(); + expect(new Config({ env, homeDir, argv: [] }).timerSeconds).toBeUndefined(); + expect(() => config.setTimerSeconds(0.5)).toThrow(); + }); + + it("ignores invalid saved budgets", async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), "timer-config-")); + homes.push(homeDir); + for (const timerSeconds of [-1, 0, 0.5, Number.MAX_VALUE]) { + await saveBackboardConfig({ timerSeconds }, homeDir); + expect(readBackboardConfig(homeDir).timerSeconds).toBeUndefined(); + } + }); +}); + +describe("/timer", () => { + it("sets, clears, and appears in suggestions", () => { + expect(parseCommand("/timer 900")).toEqual({ type: "timer", seconds: 900 }); + for (const command of ["/timer", "/timer off", "/timer 0"]) { + expect(parseCommand(command)).toEqual({ type: "timer", seconds: null }); + } + expect(slashCommandSuggestions("/timer")[0]?.type).toBe("timer"); + expect(canRunCommandAfterSessionEnd("timer")).toBe(true); + }); + + it("rejects bad input rather than silently clearing an existing budget", () => { + for (const value of ["soon", "-1", "0.5", "60 extra", "Infinity"]) { + expect(parseCommand(`/timer ${value}`)).toMatchObject({ + type: "timer", + error: expect.any(String), + }); + } + }); +}); diff --git a/tests/TimerContext.test.ts b/tests/TimerContext.test.ts new file mode 100644 index 0000000..30c0915 --- /dev/null +++ b/tests/TimerContext.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { + withoutTimerPrefix, + withoutTimerSuffix, +} from "../src/core/session/TimerContext.ts"; +import { timerBudgetPrompt, timerNotice } from "../src/prompts/timerPrompt.ts"; + +describe("timer context display filtering", () => { + it("removes only a complete tagged leading budget", () => { + const prompt = timerBudgetPrompt(60); + expect(withoutTimerPrefix(`${prompt}\n\nhello`)).toBe("hello"); + expect(withoutTimerPrefix(`${prompt}\n\n`)).toBe(""); + expect(withoutTimerPrefix(prompt)).toBe(prompt); + expect(withoutTimerPrefix(`hello\n\n${prompt}`)).toBe(`hello\n\n${prompt}`); + }); + + it("keeps unrelated, incomplete, and non-trailing tool content", () => { + const note = timerNotice(60_000, 120_000); + for (const content of [ + "output\n\nnot ours", + `output\n\n${note}\nmore output`, + `output\n\n${note.slice(0, -5)}`, + ]) { + expect(withoutTimerSuffix(content)).toBe(content); + } + expect(withoutTimerSuffix(`\n\n${note}`)).toBe(""); + expect(withoutTimerSuffix(`Error: failed\n\n${note}`)).toBe( + "Error: failed", + ); + }); +}); diff --git a/tests/TodoSchedule.test.ts b/tests/TodoSchedule.test.ts new file mode 100644 index 0000000..7d2602f --- /dev/null +++ b/tests/TodoSchedule.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "bun:test"; +import { TodoSchedule } from "../src/core/agent/timing/TodoSchedule.ts"; +import { TurnTimer } from "../src/core/agent/timing/TurnTimer.ts"; +import type { TodoItem } from "../src/core/bus/events.ts"; + +const START = 1_000_000; +const timer = () => new TurnTimer(900_000, START); +const todo = ( + id: string, + status: TodoItem["status"], + timeBudgetSeconds?: number, +): TodoItem => ({ + id, + content: id, + status, + ...(timeBudgetSeconds === undefined ? {} : { timeBudgetSeconds }), +}); + +describe("TodoSchedule", () => { + it("requires a timer and at least one allocation", () => { + const schedule = new TodoSchedule(); + expect( + schedule.onUpdate([todo("a", "in_progress", 60)], undefined, START), + ).toBeNull(); + expect( + schedule.onUpdate([todo("a", "in_progress")], timer(), START), + ).toBeNull(); + }); + + it("waits for transitions and reports actual time against the original allowance", () => { + const schedule = new TodoSchedule(); + const t = timer(); + expect( + schedule.onUpdate( + [todo("a", "in_progress", 180), todo("b", "pending", 300)], + t, + START, + ), + ).toBeNull(); + expect( + schedule.onUpdate( + [todo("a", "in_progress", 600), todo("b", "pending", 300)], + t, + START + 60_000, + ), + ).toBeNull(); + const note = schedule.onUpdate( + [todo("a", "completed", 600), todo("b", "in_progress", 300)], + t, + START + 480_000, + ); + expect(note).toContain("Previous step: planned 3m, took 8m."); + expect(note).toContain( + "1/2 steps done. 7m of the 15m budget remains; remaining steps are allotted 5m.", + ); + for (const instruction of [ + "move on", + "you should", + "hurry", + "speed up", + "stop", + "must", + ]) { + expect(note?.toLowerCase()).not.toContain(instruction); + } + expect(note).toContain("No reply needed."); + }); + + it("reports a moved-off step without claiming completion", () => { + const schedule = new TodoSchedule(); + const t = timer(); + schedule.onUpdate([todo("a", "in_progress", 120)], t, START); + const note = schedule.onUpdate( + [todo("a", "pending", 120), todo("b", "in_progress", 120)], + t, + START + 48_000, + ); + expect(note).toContain("The step you moved off: planned 2m, took 48s."); + expect(note).toContain("0/2 steps done"); + }); + + it("reports the final step when no active todo remains", () => { + const schedule = new TodoSchedule(); + const t = timer(); + schedule.onUpdate([todo("a", "in_progress", 60)], t, START); + expect( + schedule.onUpdate([todo("a", "completed", 60)], t, START + 30_000), + ).toContain( + "1/1 steps done. 14m of the 15m budget remains; remaining steps are allotted 0s.", + ); + }); + + it("flags overcommit only once and tolerates planning overhead", () => { + const schedule = new TodoSchedule(); + const t = timer(); + expect( + schedule.onUpdate( + [todo("a", "in_progress", 450), todo("b", "pending", 450)], + t, + START + 60_000, + ), + ).toBeNull(); + const plan = [todo("a", "in_progress", 600), todo("b", "pending", 600)]; + expect(schedule.onUpdate(plan, t, START + 70_000)).toContain( + "Your steps allot 20m but 13m remains.", + ); + expect(schedule.onUpdate(plan, t, START + 80_000)).toBeNull(); + }); + + it("does not count completed allocations as future work", () => { + const schedule = new TodoSchedule(); + const t = timer(); + const plan = (active: number) => + ["a", "b", "c", "d", "e"].map((id, i) => + todo( + id, + i < active ? "completed" : i === active ? "in_progress" : "pending", + 180, + ), + ); + schedule.onUpdate(plan(0), t, START); + expect(schedule.onUpdate(plan(4), t, START + 720_000)).not.toContain( + "Your steps allot", + ); + }); + + it("tracks distinct IDs even when step titles match", () => { + const schedule = new TodoSchedule(); + const t = timer(); + const a = { ...todo("a", "in_progress", 180), content: "run tests" }; + const b = { ...todo("b", "pending", 600), content: "run tests" }; + schedule.onUpdate([a, b], t, START); + expect( + schedule.onUpdate( + [ + { ...a, status: "completed" }, + { ...b, status: "in_progress" }, + ], + t, + START + 60_000, + ), + ).toContain("planned 3m, took 60s"); + }); + + it("re-arms across unbudgeted replans and forgets cleared plans", () => { + const schedule = new TodoSchedule(); + const t = timer(); + schedule.onUpdate([todo("a", "in_progress", 180)], t, START); + expect( + schedule.onUpdate([todo("b", "in_progress")], t, START + 300_000), + ).toBeNull(); + expect( + schedule.onUpdate( + [todo("b", "completed"), todo("c", "in_progress", 120)], + t, + START + 420_000, + ), + ).toContain("Previous step took 2m."); + expect(schedule.onUpdate([], t, START + 430_000)).toBeNull(); + expect( + schedule.onUpdate([todo("d", "in_progress", 60)], t, START + 440_000), + ).toBeNull(); + }); +}); diff --git a/tests/TodoWriteTool.test.ts b/tests/TodoWriteTool.test.ts index 9b0393a..2229d2d 100644 --- a/tests/TodoWriteTool.test.ts +++ b/tests/TodoWriteTool.test.ts @@ -7,6 +7,56 @@ import { import { makeContext } from "./helpers.ts"; describe("TodoWriteTool", () => { + it("preserves omitted step budgets and accepts explicit reallocations", async () => { + const previous: TodoItem[] = [ + { + id: "a", + content: "Plan", + status: "in_progress", + timeBudgetSeconds: 120, + }, + { id: "b", content: "Build", status: "pending", timeBudgetSeconds: 300 }, + ]; + const ctx = makeContext(new AbortController().signal); + ctx.getTodos = () => previous; + let actual: TodoItem[] = []; + ctx.bus.on("todos:updated", (event) => { + actual = event.todos; + }); + const tool = new TodoWriteTool(); + await tool.execute( + tool.parseInput({ + todos: [ + { content: "Plan", status: "completed" }, + { content: "Build", status: "pending", timeBudgetSeconds: 240 }, + ], + }), + ctx, + ); + expect(actual).toEqual([ + { id: "a", content: "Plan", status: "completed", timeBudgetSeconds: 120 }, + { + id: "b", + content: "Build", + status: "in_progress", + timeBudgetSeconds: 240, + }, + ]); + }); + + it("rejects invalid optional step budgets", () => { + const tool = new TodoWriteTool(); + for (const timeBudgetSeconds of [0, -1, 0.5, Infinity, Number.MAX_VALUE]) { + expect(() => + tool.parseInput({ + todos: [ + { content: "Plan", status: "in_progress", timeBudgetSeconds }, + ], + }), + ).toThrow(); + } + }); + it("trims content and preserves ids for unchanged todos", async () => { const previousTodos: TodoItem[] = [ { id: "todo_existing", content: "Plan work", status: "pending" }, diff --git a/tests/TurnTimer.test.ts b/tests/TurnTimer.test.ts new file mode 100644 index 0000000..0025371 --- /dev/null +++ b/tests/TurnTimer.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test"; +import { TurnTimer } from "../src/core/agent/timing/TurnTimer.ts"; +import { formatTimerDuration } from "../src/prompts/timerPrompt.ts"; + +const START = 1_000_000; +const BUDGET = 900_000; +const at = (remaining: number) => START + BUDGET * (1 - remaining); + +describe("TurnTimer", () => { + it("is inert with no budget and silent before halfway", () => { + expect(new TurnTimer(0, START).nextReminder(at(0))).toBeNull(); + const timer = new TurnTimer(BUDGET, START); + for (const remaining of [1, 0.9, 0.51]) { + expect(timer.nextReminder(at(remaining))).toBeNull(); + } + }); + + it("reports each threshold once using the actual clock", () => { + const timer = new TurnTimer(BUDGET, START); + for (const [remaining, text] of [ + [0.5, "7m"], + [0.25, "3m"], + [0.1, "90s"], + ] as const) { + expect(timer.nextReminder(at(remaining))).toContain( + `Time check: ${text} of the 15m`, + ); + expect(timer.nextReminder(at(remaining) + 1)).toBeNull(); + } + }); + + it("skips obsolete thresholds after a long tool round", () => { + const timer = new TurnTimer(BUDGET, START); + expect(timer.nextReminder(at(0.12))).toContain("108s"); + expect(timer.nextReminder(at(0.11))).toBeNull(); + expect(timer.nextReminder(at(0.08))).toContain("72s"); + expect(timer.nextReminder(at(0))).toBeNull(); + }); + + it("reports zero, never negative, when the first round exceeds the budget", () => { + const timer = new TurnTimer(BUDGET, START); + expect(timer.nextReminder(at(0) + 1000)).toContain("0s of the 15m"); + expect(timer.nextReminder(at(0) + 2000)).toBeNull(); + }); + + it("forced reports consume crossed thresholds without suppressing future ones", () => { + const timer = new TurnTimer(BUDGET, START); + expect(timer.reportNow(at(0.8))).toContain("12m"); + expect(timer.nextReminder(at(0.5))).toContain("7m"); + expect(timer.reportNow(at(0.2))).toContain("3m"); + expect(timer.nextReminder(at(0.19))).toBeNull(); + expect(timer.nextReminder(at(0.1))).toContain("90s"); + }); + + it("uses independent state for subsequent turns", () => { + new TurnTimer(BUDGET, START).nextReminder(at(0)); + const next = new TurnTimer(BUDGET, at(0)); + expect(next.remainingMs(at(0))).toBe(BUDGET); + expect(next.nextReminder(at(0))).toBeNull(); + expect(next.nextReminder(at(0) + BUDGET / 2)).toContain("7m"); + }); + + it("rounds down and uses seconds below two minutes", () => { + expect( + [0, -1, 119_999, 120_000, 179_999, 900_000].map(formatTimerDuration), + ).toEqual(["0s", "0s", "119s", "2m", "2m", "15m"]); + }); +}); diff --git a/tests/TurnTiming.test.ts b/tests/TurnTiming.test.ts new file mode 100644 index 0000000..2c138ef --- /dev/null +++ b/tests/TurnTiming.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "bun:test"; +import { TurnTiming } from "../src/core/agent/timing/TurnTiming.ts"; +import type { TodoItem } from "../src/core/bus/events.ts"; +import type { ToolOutput } from "../src/core/tools/ToolScheduler.ts"; + +const START = 1_000_000; +function output(name: string, extra = {}): ToolOutput { + return { + tool_call_id: name, + output: name, + metadata: { name, readOnly: false, error: false, ...extra }, + }; +} +const todo = (status: TodoItem["status"]): TodoItem => ({ + id: "a", + content: "a", + status, + timeBudgetSeconds: 180, +}); + +describe("TurnTiming outbound decoration", () => { + it("appends only to the last output without mutating the originals", () => { + const timing = new TurnTiming(900, START); + const originals = [ + output("read"), + { ...output("execute", { error: true }), output: "Error: failed" }, + ]; + const wire = timing.append(originals, [], START + 450_000); + expect(wire[0]).toEqual({ tool_call_id: "read", output: "read" }); + expect(wire[1]?.output).toStartWith( + 'Error: failed\n\n', + ); + expect(originals[1]?.output).toBe("Error: failed"); + expect(wire[1]).not.toHaveProperty("metadata"); + }); + + it("does not consume thresholds on empty rounds", () => { + const timing = new TurnTiming(900, START); + expect(timing.append([], [], START + 450_000)).toEqual([]); + expect( + timing.append([output("read")], [], START + 450_000)[0]?.output, + ).toContain("Time check:"); + }); + + it("recognizes successful wire-name TodoWrite calls and combines both notices", () => { + const timing = new TurnTiming(900, START); + timing.append([output("todo_write")], [todo("in_progress")], START); + const wire = timing.append( + [output("todo_write"), output("read")], + [todo("completed")], + START + 480_000, + ); + expect(wire[0]?.output).toBe("todo_write"); + expect(wire[1]?.output).toContain("Time check: 7m"); + expect(wire[1]?.output).toContain("Previous step: planned 3m, took 8m."); + }); + + it("does not start schedule tracking from a failed todo update", () => { + const timing = new TurnTiming(900, START); + timing.append( + [output("todo_write", { error: true })], + [todo("in_progress")], + START, + ); + const wire = timing.append( + [output("todo_write")], + [todo("completed")], + START + 60_000, + ); + expect(wire[0]?.output).not.toContain("Previous step"); + }); + + it("reports current time when a tool returns work still running", () => { + const timing = new TurnTiming(900, START); + expect( + timing.append( + [output("execute", { stillRunning: true })], + [], + START + 60_000, + )[0]?.output, + ).toContain("Time check: 14m"); + expect(timing.append([output("read")], [], START + 60_000)[0]?.output).toBe( + "read", + ); + }); +}); From a96ee6ebaf5f0b3fef2e4de119cd708e9302dbd4 Mon Sep 17 00:00:00 2001 From: q <167793812+0vp@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:56:57 -0400 Subject: [PATCH 2/2] Fix timer accounting and resume edge cases --- src/core/agent/AgentLoop.ts | 10 + src/core/agent/timing/TodoSchedule.ts | 23 +- src/core/agent/timing/TurnTiming.ts | 66 ++++- src/core/agent/timing/types.ts | 6 + src/core/bus/events.ts | 2 +- src/core/todos/TodoList.ts | 4 +- src/providers/backboard/threads.ts | 8 +- src/tools/TodoWriteTool.tsx | 2 +- src/ui/App.tsx | 26 +- src/ui/commands/timer.ts | 27 ++ tests/AgentLoop.timer-batching.test.ts | 114 ++++++++ tests/AgentLoop.timer-errors.test.ts | 353 +++++++++++++++++++++++++ tests/Session.test.ts | 2 +- tests/Threads.test.ts | 12 + tests/TimerCommand.test.ts | 73 +++++ tests/TodoSchedule.test.ts | 49 +++- tests/TurnTiming.test.ts | 4 + 17 files changed, 713 insertions(+), 68 deletions(-) create mode 100644 src/core/agent/timing/types.ts create mode 100644 src/ui/commands/timer.ts create mode 100644 tests/AgentLoop.timer-batching.test.ts create mode 100644 tests/AgentLoop.timer-errors.test.ts create mode 100644 tests/TimerCommand.test.ts diff --git a/src/core/agent/AgentLoop.ts b/src/core/agent/AgentLoop.ts index 329480d..b8883b9 100644 --- a/src/core/agent/AgentLoop.ts +++ b/src/core/agent/AgentLoop.ts @@ -141,7 +141,15 @@ export class AgentLoop { notifications.activeNotificationHidesResponse(), }); + let detachTiming: (() => void) | undefined; try { + if (timing) { + detachTiming = bus.on("todos:updated", (event) => { + if (event.toolCallId && !ctx.signal.aborted) { + timing.recordTodoUpdate(event.toolCallId, event.todos); + } + }); + } const initialThinking = this.resolveThinking(); const messageOptions = { signal: ctx.signal, @@ -202,6 +210,8 @@ export class AgentLoop { durationMs: turn.durationMs(), }); return "failed"; + } finally { + detachTiming?.(); } bus.emit({ diff --git a/src/core/agent/timing/TodoSchedule.ts b/src/core/agent/timing/TodoSchedule.ts index 0a7bfa5..f008d0c 100644 --- a/src/core/agent/timing/TodoSchedule.ts +++ b/src/core/agent/timing/TodoSchedule.ts @@ -1,7 +1,5 @@ import { - timerReminder, todoOvercommitNotice, - todoProgressNotice, todoStepNotice, } from "../../../prompts/timerPrompt.ts"; import type { TodoItem } from "../../bus/events.ts"; @@ -21,14 +19,14 @@ export class TodoSchedule { this.activeBudgetMs = undefined; } - onUpdate( + recordUpdate( todos: readonly TodoItem[], timer: TurnTimer | undefined, now = Date.now(), - ): string | null { + ): string[] { if (!timer || todos.length === 0) { this.forgetActive(); - return null; + return []; } const active = todos.find((todo) => todo.status === "in_progress"); const changed = this.activeId !== undefined && active?.id !== this.activeId; @@ -52,8 +50,7 @@ export class TodoSchedule { ? undefined : active.timeBudgetSeconds * 1000; } - if (!todos.some((todo) => todo.timeBudgetSeconds !== undefined)) - return null; + if (!todos.some((todo) => todo.timeBudgetSeconds !== undefined)) return []; const remainingMs = timer.remainingMs(now); const aheadMs = todos @@ -73,16 +70,6 @@ export class TodoSchedule { todoStepNotice(previous?.status === "completed", tookMs, plannedMs), ); } - if (lines.length === 0) return null; - lines.push( - todoProgressNotice( - todos.filter((todo) => todo.status === "completed").length, - todos.length, - remainingMs, - timer.totalBudgetMs, - aheadMs, - ), - ); - return timerReminder(lines); + return lines; } } diff --git a/src/core/agent/timing/TurnTiming.ts b/src/core/agent/timing/TurnTiming.ts index 0a79f7d..7c0655d 100644 --- a/src/core/agent/timing/TurnTiming.ts +++ b/src/core/agent/timing/TurnTiming.ts @@ -1,19 +1,37 @@ +import { + timerReminder, + todoProgressNotice, +} from "../../../prompts/timerPrompt.ts"; import type { SubmitToolOutputsRequest } from "../../../providers/backboard/types.ts"; import type { TodoItem } from "../../bus/events.ts"; import { canonicalToolName } from "../../tools/names.ts"; import type { ToolOutput } from "../../tools/ToolScheduler.ts"; import { TodoSchedule } from "./TodoSchedule.ts"; import { TurnTimer } from "./TurnTimer.ts"; +import type { TimedTodoUpdate } from "./types.ts"; /** Decorates outbound copies only. Local results and error prefixes stay intact. */ export class TurnTiming { private readonly timer: TurnTimer; private readonly schedule = new TodoSchedule(); + private readonly todoUpdates = new Map(); constructor(seconds: number, startedAt: number) { this.timer = new TurnTimer(seconds * 1000, startedAt); } + /** Called synchronously on the actual update, not after later batched tools. */ + recordTodoUpdate( + toolCallId: string, + todos: readonly TodoItem[], + at = Date.now(), + ): void { + this.todoUpdates.set(toolCallId, { + todos: todos.map((todo) => ({ ...todo })), + at, + }); + } + append( outputs: readonly ToolOutput[], todos: readonly TodoItem[], @@ -28,16 +46,44 @@ export class TurnTiming { const timerNote = outputs.some((output) => output.metadata?.stillRunning) ? this.timer.reportNow(now) : this.timer.nextReminder(now); - const todoUpdated = outputs.some( - (output) => - output.metadata && - !output.metadata.error && - canonicalToolName(output.metadata.name) === - canonicalToolName("TodoWrite"), - ); - const todoNote = todoUpdated - ? this.schedule.onUpdate(todos, this.timer, now) - : null; + const todoLines: string[] = []; + let reportedTodos = todos; + for (const output of outputs) { + const update = this.todoUpdates.get(output.tool_call_id); + this.todoUpdates.delete(output.tool_call_id); + // Post-tool hooks can reject an update. Only commit successful calls. + if ( + !update || + !output.metadata || + output.metadata.error || + canonicalToolName(output.metadata.name) !== + canonicalToolName("TodoWrite") + ) + continue; + todoLines.push( + ...this.schedule.recordUpdate(update.todos, this.timer, update.at), + ); + reportedTodos = update.todos; + } + const todoNote = + todoLines.length === 0 + ? null + : timerReminder([ + ...todoLines, + todoProgressNotice( + reportedTodos.filter((todo) => todo.status === "completed") + .length, + reportedTodos.length, + this.timer.remainingMs(now), + this.timer.totalBudgetMs, + reportedTodos + .filter((todo) => todo.status !== "completed") + .reduce( + (sum, todo) => sum + (todo.timeBudgetSeconds ?? 0) * 1000, + 0, + ), + ), + ]); for (const note of [timerNote, todoNote]) { if (note) last.output += `\n\n${note}`; } diff --git a/src/core/agent/timing/types.ts b/src/core/agent/timing/types.ts new file mode 100644 index 0000000..fc4c542 --- /dev/null +++ b/src/core/agent/timing/types.ts @@ -0,0 +1,6 @@ +import type { TodoItem } from "../../bus/events.ts"; + +export interface TimedTodoUpdate { + todos: readonly TodoItem[]; + at: number; +} diff --git a/src/core/bus/events.ts b/src/core/bus/events.ts index b1561b1..a1c5663 100644 --- a/src/core/bus/events.ts +++ b/src/core/bus/events.ts @@ -154,7 +154,7 @@ export type AgentEvent = | { type: "input:request"; request: AskUserRequest } | { type: "input:response"; response: AskUserResponse } | { type: "permission:mode"; mode: PermissionMode } - | { type: "todos:updated"; todos: TodoItem[] } + | { type: "todos:updated"; todos: TodoItem[]; toolCallId?: string } | { type: "usage"; usage: UsageInfo } | { type: "system:warning"; message: string } | { type: "run:error"; error: string }; diff --git a/src/core/todos/TodoList.ts b/src/core/todos/TodoList.ts index 076b4d8..01ef4eb 100644 --- a/src/core/todos/TodoList.ts +++ b/src/core/todos/TodoList.ts @@ -128,7 +128,7 @@ function todoDraftsFromInput(input: unknown): TodoDraft[] { status?: unknown; timeBudgetSeconds?: unknown; }; - if (typeof content !== "string" || content.length === 0) continue; + if (typeof content !== "string" || content.trim().length === 0) continue; if ( status !== "pending" && status !== "in_progress" && @@ -136,7 +136,7 @@ function todoDraftsFromInput(input: unknown): TodoDraft[] { ) continue; todos.push({ - content, + content: content.trim(), status, ...(isTimerSeconds(timeBudgetSeconds) ? { timeBudgetSeconds } : {}), }); diff --git a/src/providers/backboard/threads.ts b/src/providers/backboard/threads.ts index 4ee2ddb..1b0830b 100644 --- a/src/providers/backboard/threads.ts +++ b/src/providers/backboard/threads.ts @@ -20,12 +20,14 @@ export { truncate } from "../../utils/string.ts"; export function threadDisplayTitle(thread: BackboardThread): string { const title = thread.title?.trim(); if (title) return title; - const firstUserPreview = thread.first_user_message - ?.replace(/\s+/g, " ") + const firstUserPreview = withoutTimerPrefix(thread.first_user_message ?? "") + .replace(/\s+/g, " ") .trim(); if (firstUserPreview) return truncate(firstUserPreview, 60); const firstUser = thread.messages.find((message) => message.role === "user"); - const content = firstUser?.content?.replace(/\s+/g, " ").trim(); + const content = withoutTimerPrefix(firstUser?.content ?? "") + .replace(/\s+/g, " ") + .trim(); if (content) return truncate(content, 60); return `Session ${thread.thread_id.slice(0, 8)}`; } diff --git a/src/tools/TodoWriteTool.tsx b/src/tools/TodoWriteTool.tsx index c33b856..988b4d8 100644 --- a/src/tools/TodoWriteTool.tsx +++ b/src/tools/TodoWriteTool.tsx @@ -88,7 +88,7 @@ export class TodoWriteTool extends Tool { previousTodos, ); - ctx.bus.emit({ type: "todos:updated", todos }); + ctx.bus.emit({ type: "todos:updated", todos, toolCallId: ctx.toolCallId }); const readback = formatTodoReadback(todos); return ok({ count: todos.length }, readback, readback); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index a41755a..d1b5546 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -72,6 +72,7 @@ import { HELP_TEXT, parseCommand, } from "./commands/index.ts"; +import { handleTimerCommand } from "./commands/timer.ts"; import { AskUserPrompt } from "./components/AskUserPrompt.tsx"; import { ContextPanel } from "./components/ContextPanel.tsx"; import { HookAddForm } from "./components/HookAddForm.tsx"; @@ -1702,30 +1703,7 @@ export function App({ break; } case "timer": { - if (command.error) { - agent.notice(command.error, "error"); - break; - } - if (command.seconds === null && config.timerSeconds === undefined) { - agent.notice( - "No time budget set. Use /timer to set one.", - "warning", - ); - break; - } - const next = command.seconds ?? undefined; - config.setTimerSeconds(next); - void config.saveTimerPreference().catch((err) => { - agent.notice( - `Failed to save timer preference: ${errorMessage(err)}`, - "error", - ); - }); - agent.notice( - next === undefined - ? "Time budget cleared for subsequent turns." - : `Time budget set to ${next}s per turn, starting with the next turn.`, - ); + void handleTimerCommand(command, config, agent.notice); break; } case "update": diff --git a/src/ui/commands/timer.ts b/src/ui/commands/timer.ts new file mode 100644 index 0000000..2d06697 --- /dev/null +++ b/src/ui/commands/timer.ts @@ -0,0 +1,27 @@ +import type { Config } from "../../config/Config.ts"; +import { errorMessage } from "../../utils/errors.ts"; +import type { Command } from "./index.ts"; + +export async function handleTimerCommand( + command: Extract, + config: Config, + notice: (text: string, level?: "info" | "warning" | "error") => void, +): Promise { + if (command.error) { + notice(command.error, "error"); + return; + } + const next = command.seconds ?? undefined; + config.setTimerSeconds(next); + const save = config.saveTimerPreference(); + notice( + next === undefined + ? "Time budget cleared for subsequent turns." + : `Time budget set to ${next}s per turn, starting with the next turn.`, + ); + try { + await save; + } catch (err) { + notice(`Failed to save timer preference: ${errorMessage(err)}`, "error"); + } +} diff --git a/tests/AgentLoop.timer-batching.test.ts b/tests/AgentLoop.timer-batching.test.ts new file mode 100644 index 0000000..8acd5f6 --- /dev/null +++ b/tests/AgentLoop.timer-batching.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, spyOn } from "bun:test"; +import { AgentLoop } from "../src/core/agent/AgentLoop.ts"; +import { EventBus } from "../src/core/bus/EventBus.ts"; +import type { ToolCallRef } from "../src/core/bus/events.ts"; +import { Session } from "../src/core/session/Session.ts"; +import { ToolRegistry } from "../src/core/tools/ToolRegistry.ts"; +import { ToolScheduler } from "../src/core/tools/ToolScheduler.ts"; +import type { AgentClient } from "../src/providers/AgentClient.ts"; +import type { + ProviderEvent, + SubmitToolOutputsRequest, +} from "../src/providers/backboard/types.ts"; +import { TodoWriteTool } from "../src/tools/TodoWriteTool.tsx"; +import { makeContext, TestTool } from "./helpers.ts"; + +const update = ( + id: string, + status: "in_progress" | "completed", +): ToolCallRef => ({ + id, + name: "todo_write", + input: { todos: [{ content: "Implement", status, timeBudgetSeconds: 180 }] }, +}); +const work: ToolCallRef = { id: "work", name: "Execute", input: {} }; + +async function runRounds( + rounds: ToolCallRef[][], +): Promise { + let now = 1_000_000; + const clock = spyOn(Date, "now").mockImplementation(() => now); + const bus = new EventBus(); + const session = new Session("timer-batching"); + const detach = session.attach(bus); + const results: SubmitToolOutputsRequest[] = []; + let nextRound = 0; + const events = function* (): Generator { + const calls = rounds[nextRound++]; + if (calls) yield { kind: "requires_action", runId: "run", calls }; + else yield { kind: "completed" }; + }; + const client = { + async *runMessage(): AsyncIterable { + yield { kind: "thread", threadId: "thread" }; + yield* events(); + }, + async *runToolOutputs( + request: SubmitToolOutputsRequest, + ): AsyncIterable { + results.push(request); + yield* events(); + }, + } as unknown as AgentClient; + const registry = new ToolRegistry([ + new TodoWriteTool(), + new TestTool({ + name: "Execute", + readOnly: false, + onStart: () => { + now += 180_000; + }, + }), + ]); + try { + const loop = new AgentLoop({ + client, + bus, + session, + scheduler: new ToolScheduler(registry, bus), + tools: [], + systemPrompt: "static", + model: { provider: "test", model: "test" }, + memory: "off", + memoryProfile: "default", + thinking: undefined, + timerSeconds: 900, + turnStartedAt: now, + }); + expect( + await loop.run("Fix it", { + ...makeContext(new AbortController().signal, bus), + getTodos: () => session.todos, + }), + ).toBe("completed"); + expect(JSON.stringify(session.getMessages())).not.toContain( + "Previous step:", + ); + return results; + } finally { + detach(); + clock.mockRestore(); + } +} + +describe("timer accounting across real tool batches", () => { + it("includes work batched after activation in the step's elapsed time", async () => { + const results = await runRounds([ + [update("start", "in_progress"), work], + [update("done", "completed")], + ]); + expect(results[1]?.tool_outputs[0]?.output).toContain( + "Previous step: planned 3m, took 3m.", + ); + }); + + it("retains transitions completed within one tool round", async () => { + const results = await runRounds([ + [update("start", "in_progress"), work, update("done", "completed")], + ]); + expect(results[0]?.tool_outputs.at(-1)?.output).toContain( + "Previous step: planned 3m, took 3m.", + ); + expect(results[0]?.tool_outputs.at(-1)?.output).toContain("1/1 steps done"); + }); +}); diff --git a/tests/AgentLoop.timer-errors.test.ts b/tests/AgentLoop.timer-errors.test.ts new file mode 100644 index 0000000..99c3a02 --- /dev/null +++ b/tests/AgentLoop.timer-errors.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, it, spyOn } from "bun:test"; +import { AgentLoop } from "../src/core/agent/AgentLoop.ts"; +import { TurnTiming } from "../src/core/agent/timing/TurnTiming.ts"; +import { EventBus } from "../src/core/bus/EventBus.ts"; +import type { ToolCallRef } from "../src/core/bus/events.ts"; +import { emptyRuleSet } from "../src/core/permissions/PermissionRules.ts"; +import { Session } from "../src/core/session/Session.ts"; +import { ToolHookPipeline } from "../src/core/tools/ToolHookPipeline.ts"; +import { ToolRegistry } from "../src/core/tools/ToolRegistry.ts"; +import { ToolScheduler } from "../src/core/tools/ToolScheduler.ts"; +import type { AgentClient } from "../src/providers/AgentClient.ts"; +import type { + ProviderEvent, + SendMessageRequest, + SubmitToolOutputsRequest, +} from "../src/providers/backboard/types.ts"; +import { TodoWriteTool } from "../src/tools/TodoWriteTool.tsx"; +import { makeContext, TestTool } from "./helpers.ts"; + +const todo = ( + id: string, + status: "in_progress" | "completed", +): ToolCallRef => ({ + id, + name: "todo_write", + input: { todos: [{ content: "Implement", status, timeBudgetSeconds: 180 }] }, +}); +const work = (id = "work"): ToolCallRef => ({ id, name: "Execute", input: {} }); +const action = (calls: ToolCallRef[]): ProviderEvent => ({ + kind: "requires_action", + runId: "run", + calls, +}); + +async function harness( + test: (h: { + bus: EventBus; + session: Session; + abort: AbortController; + advance: (ms: number) => void; + run: ( + first: ProviderEvent[], + next: (attempt: number) => ProviderEvent[], + tools?: TestTool[], + permissions?: ReturnType["permissions"], + ) => Promise; + requests: SubmitToolOutputsRequest[]; + preserved: SubmitToolOutputsRequest[]; + messages: SendMessageRequest[]; + }) => Promise, +): Promise { + let now = 1_000_000; + const clock = spyOn(Date, "now").mockImplementation(() => now); + const record = spyOn(TurnTiming.prototype, "recordTodoUpdate"); + const bus = new EventBus(); + const session = new Session("timer-errors"); + const detachSession = session.attach(bus); + const originalOn = bus.on.bind(bus); + let timingDetaches = 0; + const subscribe = spyOn(bus, "on").mockImplementation((type, listener) => { + const detach = originalOn(type, listener); + return () => { + if (type === "todos:updated") timingDetaches++; + detach(); + }; + }); + const abort = new AbortController(); + const requests: SubmitToolOutputsRequest[] = []; + const preserved: SubmitToolOutputsRequest[] = []; + const messages: SendMessageRequest[] = []; + try { + await test({ + bus, + session, + abort, + requests, + preserved, + messages, + advance: (ms) => { + now += ms; + }, + run: async (first, next, tools = [], permissions) => { + const client = { + async *runMessage(request: SendMessageRequest) { + messages.push(request); + if (messages.length > 1) throw new Error("Unexpected notification"); + yield { kind: "thread", threadId: "thread" }; + yield* first; + }, + async *runToolOutputs(request: SubmitToolOutputsRequest) { + // Snapshot each attempt so accidental in-place redecorations are visible. + requests.push(structuredClone(request)); + yield* next(requests.length); + }, + async preserveFailedToolOutputs(request: SubmitToolOutputsRequest) { + preserved.push(structuredClone(request)); + return null; + }, + } as unknown as AgentClient; + const loop = new AgentLoop({ + client, + bus, + session, + scheduler: new ToolScheduler( + new ToolRegistry([new TodoWriteTool(), ...tools]), + bus, + ), + tools: [], + systemPrompt: "static", + model: { provider: "test", model: "test" }, + memory: "off", + memoryProfile: "default", + thinking: undefined, + timerSeconds: 900, + turnStartedAt: 1_000_000, + }); + const status = await loop.run("Fix it", { + ...makeContext(abort.signal, bus), + getTodos: () => session.todos, + permissions, + }); + expect(timingDetaches).toBe(1); + const count = record.mock.calls.length; + bus.emit({ type: "todos:updated", toolCallId: "late", todos: [] }); + expect(record.mock.calls.length).toBe(count); + expect(JSON.stringify(session.getMessages())).not.toContain( + "Time check:", + ); + expect(JSON.stringify(session.getMessages())).not.toContain( + "Previous step:", + ); + return status; + }, + }); + } finally { + subscribe.mockRestore(); + detachSession(); + record.mockRestore(); + clock.mockRestore(); + } +} + +describe("timer error paths through real tool rounds", () => { + it("cancels after TodoWrite without partial submissions or preserved notices", async () => { + await harness(async (h) => { + const status = await h.run( + [ + action([ + todo("start", "in_progress"), + work(), + todo("done", "completed"), + ]), + ], + () => [{ kind: "completed" }], + [ + new TestTool({ + name: "Execute", + readOnly: false, + onStart: () => { + h.advance(480_000); + h.abort.abort(); + }, + }), + ], + ); + expect(status).toBe("cancelled"); + expect(h.requests).toHaveLength(0); + expect(h.preserved).toHaveLength(1); + expect(h.preserved[0]?.tool_outputs).toEqual([ + { + tool_call_id: "start", + output: "Updated 1 todos: [in_progress] Implement", + }, + { + tool_call_id: "work", + output: + "Error: Tool execution was interrupted before results were submitted.", + }, + { + tool_call_id: "done", + output: + "Error: Tool execution was interrupted before results were submitted.", + }, + ]); + }); + }); + + it("retries the same decorated outputs once without rerunning tools or resetting the budget", async () => { + await harness(async (h) => { + let starts = 0; + let updates = 0; + const detach = h.bus.on("todos:updated", (event) => { + if (event.toolCallId !== "late") updates++; + }); + try { + expect( + await h.run( + [ + action([ + todo("start", "in_progress"), + work(), + todo("done", "completed"), + ]), + ], + (attempt) => { + if (attempt === 1) { + h.advance(240_000); + return [ + { + kind: "failed", + error: "Failed to continue streaming after tool outputs.", + retryable: true, + }, + ]; + } + if (attempt === 2) return [action([work("after-retry")])]; + return [{ kind: "completed" }]; + }, + [ + new TestTool({ + name: "Execute", + readOnly: false, + onStart: () => { + starts++; + if (starts === 1) h.advance(480_000); + }, + }), + ], + ), + ).toBe("completed"); + expect(starts).toBe(2); + expect(updates).toBe(2); + expect(h.messages).toHaveLength(1); + expect(h.requests).toHaveLength(3); + expect(h.requests[1]).toEqual(h.requests[0]); + const output = h.requests[0]?.tool_outputs.at(-1)?.output ?? ""; + expect(output.match(/Time check:/g)).toHaveLength(1); + expect( + output.match(/Previous step: planned 3m, took 8m\./g), + ).toHaveLength(1); + expect(output.match(/1\/1 steps done/g)).toHaveLength(1); + expect(h.requests[2]?.tool_outputs[0]?.output).toContain( + "3m of the 15m budget remains", + ); + expect(h.requests[2]?.tool_outputs[0]?.output).not.toContain( + "Previous step:", + ); + expect(h.preserved).toHaveLength(0); + } finally { + detach(); + } + }); + }); + + it("detaches timing when a non-retryable continuation fails", async () => { + await harness(async (h) => { + expect( + await h.run([action([todo("start", "in_progress")])], () => [ + { kind: "failed", error: "terminal provider failure" }, + ]), + ).toBe("failed"); + expect(h.requests).toHaveLength(1); + }); + }); + + for (const denied of [false, true]) { + it(`keeps the Error prefix and local error classification for ${denied ? "denied" : "throwing"} tools with timer notices`, async () => { + await harness(async (h) => { + let starts = 0; + const tool = new TestTool({ + name: "Execute", + readOnly: false, + throws: true, + onStart: () => { + starts++; + }, + }); + const permission = spyOn(tool, "checkPermissions").mockReturnValue({ + behavior: "deny", + reason: "test denial", + }); + try { + h.advance(480_000); + expect( + await h.run( + [action([work()])], + () => [{ kind: "completed" }], + [tool], + denied + ? { mode: "manual", rules: emptyRuleSet(), interactive: false } + : undefined, + ), + ).toBe("completed"); + const error = denied ? "Error: test denial" : "Error: tool failed"; + expect(starts).toBe(denied ? 0 : 1); + expect(h.requests[0]?.tool_outputs[0]?.output).toStartWith( + `${error}\n\n`, + ); + expect(h.requests[0]?.tool_outputs[0]?.output).toContain( + "Time check:", + ); + const local = h.session + .getMessages() + .find((message) => message.role === "tool"); + expect(local?.results[0]).toMatchObject({ + output: error, + isError: true, + }); + } finally { + permission.mockRestore(); + } + }); + }); + } + + it("does not start step accounting when a real TodoWrite update is rejected after execution", async () => { + const original = ToolHookPipeline.prototype.applyPostToolHooks; + const post = spyOn( + ToolHookPipeline.prototype, + "applyPostToolHooks", + ).mockImplementation(async function (this: ToolHookPipeline, ref, ...args) { + if (ref.id === "rejected") + return { output: "Error: rejected todo update", denied: true }; + return original.call(this, ref, ...args); + }); + try { + await harness(async (h) => { + expect( + await h.run( + [action([todo("rejected", "in_progress")])], + (attempt) => { + if (attempt === 1) { + h.advance(480_000); + return [action([todo("done", "completed")])]; + } + return [{ kind: "completed" }]; + }, + ), + ).toBe("completed"); + expect(h.requests[0]?.tool_outputs[0]?.output).toBe( + "Error: rejected todo update", + ); + const completed = h.requests[1]?.tool_outputs[0]?.output ?? ""; + expect(completed).toStartWith("Updated 1 todos: [completed] Implement"); + expect(completed).toContain("Time check:"); + expect(completed).not.toContain("Previous step:"); + expect(completed).not.toContain("steps done"); + }); + } finally { + post.mockRestore(); + } + }); +}); diff --git a/tests/Session.test.ts b/tests/Session.test.ts index 055f191..467b8ea 100644 --- a/tests/Session.test.ts +++ b/tests/Session.test.ts @@ -25,7 +25,7 @@ describe("Session", () => { threadId: "thread", messages: [ ...write("plan", [ - { content: "A", status: "in_progress", timeBudgetSeconds: 120 }, + { content: " A ", status: "in_progress", timeBudgetSeconds: 120 }, { content: "B", status: "pending", timeBudgetSeconds: 300 }, ]), ...write("advance", [ diff --git a/tests/Threads.test.ts b/tests/Threads.test.ts index e10807c..9fb6189 100644 --- a/tests/Threads.test.ts +++ b/tests/Threads.test.ts @@ -12,6 +12,7 @@ import { import { backboardThreadToMessages, sortThreadsByUpdatedAt, + threadDisplayTitle, threadUpdatedAt, } from "../src/providers/backboard/threads.ts"; import type { @@ -20,6 +21,17 @@ import type { } from "../src/providers/backboard/types.ts"; describe("Backboard thread helpers", () => { + it("uses the human task for timed session title fallbacks", () => { + const content = `${timerBudgetPrompt(900)}\n\nFix authentication`; + const thread = threadWithMessages([{ role: "user", content }]); + expect(threadDisplayTitle(thread)).toBe("Fix authentication"); + expect(threadDisplayTitle({ ...thread, first_user_message: content })).toBe( + "Fix authentication", + ); + expect(threadDisplayTitle({ ...thread, title: "Explicit title" })).toBe( + "Explicit title", + ); + }); it("restores human input and tool output without tagged timer context", () => { const original = "Error: tests failed\nkeep this real output"; diff --git a/tests/TimerCommand.test.ts b/tests/TimerCommand.test.ts new file mode 100644 index 0000000..47f306a --- /dev/null +++ b/tests/TimerCommand.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + readBackboardConfig, + saveBackboardConfig, +} from "../src/config/backboardConfig.ts"; +import { Config } from "../src/config/Config.ts"; +import { handleTimerCommand } from "../src/ui/commands/timer.ts"; + +const homes: string[] = []; +const env = { apiKey: "test", apiUrl: "https://example.test/api" }; +afterEach(async () => { + await Promise.all( + homes.splice(0).map((home) => rm(home, { recursive: true, force: true })), + ); +}); + +describe("interactive timer commands", () => { + it("clears a saved timer even when a CLI override already disabled it", async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), "timer-command-")); + homes.push(homeDir); + await saveBackboardConfig({ timerSeconds: 900, notify: true }, homeDir); + const config = new Config({ env, homeDir, argv: ["--no-timer"] }); + expect(config.timerSeconds).toBeUndefined(); + const notices: string[] = []; + await handleTimerCommand({ type: "timer", seconds: null }, config, (text) => + notices.push(text), + ); + expect(readBackboardConfig(homeDir).timerSeconds).toBeUndefined(); + expect(readBackboardConfig(homeDir).notify).toBe(true); + expect(notices).toContain("Time budget cleared for subsequent turns."); + }); + + it("reports persistence errors without rejecting the command", async () => { + const config = new Config({ env, argv: [] }); + const save = spyOn(config, "saveTimerPreference").mockRejectedValue( + new Error("disk unavailable"), + ); + const notices: Array<[string, string | undefined]> = []; + try { + await handleTimerCommand( + { type: "timer", seconds: 60 }, + config, + (text, level) => notices.push([text, level]), + ); + expect(config.timerSeconds).toBe(60); + expect(notices).toContainEqual([ + "Failed to save timer preference: disk unavailable", + "error", + ]); + } finally { + save.mockRestore(); + } + }); + + it("keeps the current timer when the command is invalid", async () => { + const config = new Config({ env, argv: ["--timer", "900"] }); + const save = spyOn(config, "saveTimerPreference").mockResolvedValue(); + try { + await handleTimerCommand( + { type: "timer", seconds: null, error: "Invalid budget" }, + config, + () => {}, + ); + expect(config.timerSeconds).toBe(900); + expect(save).not.toHaveBeenCalled(); + } finally { + save.mockRestore(); + } + }); +}); diff --git a/tests/TodoSchedule.test.ts b/tests/TodoSchedule.test.ts index 7d2602f..f45d774 100644 --- a/tests/TodoSchedule.test.ts +++ b/tests/TodoSchedule.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from "bun:test"; import { TodoSchedule } from "../src/core/agent/timing/TodoSchedule.ts"; import { TurnTimer } from "../src/core/agent/timing/TurnTimer.ts"; import type { TodoItem } from "../src/core/bus/events.ts"; +import { + timerReminder, + todoProgressNotice, +} from "../src/prompts/timerPrompt.ts"; const START = 1_000_000; const timer = () => new TurnTimer(900_000, START); @@ -16,9 +20,38 @@ const todo = ( ...(timeBudgetSeconds === undefined ? {} : { timeBudgetSeconds }), }); +function createSchedule() { + const schedule = new TodoSchedule(); + return { + onUpdate( + todos: readonly TodoItem[], + budget: TurnTimer | undefined, + now: number, + ) { + const lines = schedule.recordUpdate(todos, budget, now); + if (!budget || lines.length === 0) return null; + return timerReminder([ + ...lines, + todoProgressNotice( + todos.filter((todo) => todo.status === "completed").length, + todos.length, + budget.remainingMs(now), + budget.totalBudgetMs, + todos + .filter((todo) => todo.status !== "completed") + .reduce( + (sum, todo) => sum + (todo.timeBudgetSeconds ?? 0) * 1000, + 0, + ), + ), + ]); + }, + }; +} + describe("TodoSchedule", () => { it("requires a timer and at least one allocation", () => { - const schedule = new TodoSchedule(); + const schedule = createSchedule(); expect( schedule.onUpdate([todo("a", "in_progress", 60)], undefined, START), ).toBeNull(); @@ -28,7 +61,7 @@ describe("TodoSchedule", () => { }); it("waits for transitions and reports actual time against the original allowance", () => { - const schedule = new TodoSchedule(); + const schedule = createSchedule(); const t = timer(); expect( schedule.onUpdate( @@ -67,7 +100,7 @@ describe("TodoSchedule", () => { }); it("reports a moved-off step without claiming completion", () => { - const schedule = new TodoSchedule(); + const schedule = createSchedule(); const t = timer(); schedule.onUpdate([todo("a", "in_progress", 120)], t, START); const note = schedule.onUpdate( @@ -80,7 +113,7 @@ describe("TodoSchedule", () => { }); it("reports the final step when no active todo remains", () => { - const schedule = new TodoSchedule(); + const schedule = createSchedule(); const t = timer(); schedule.onUpdate([todo("a", "in_progress", 60)], t, START); expect( @@ -91,7 +124,7 @@ describe("TodoSchedule", () => { }); it("flags overcommit only once and tolerates planning overhead", () => { - const schedule = new TodoSchedule(); + const schedule = createSchedule(); const t = timer(); expect( schedule.onUpdate( @@ -108,7 +141,7 @@ describe("TodoSchedule", () => { }); it("does not count completed allocations as future work", () => { - const schedule = new TodoSchedule(); + const schedule = createSchedule(); const t = timer(); const plan = (active: number) => ["a", "b", "c", "d", "e"].map((id, i) => @@ -125,7 +158,7 @@ describe("TodoSchedule", () => { }); it("tracks distinct IDs even when step titles match", () => { - const schedule = new TodoSchedule(); + const schedule = createSchedule(); const t = timer(); const a = { ...todo("a", "in_progress", 180), content: "run tests" }; const b = { ...todo("b", "pending", 600), content: "run tests" }; @@ -143,7 +176,7 @@ describe("TodoSchedule", () => { }); it("re-arms across unbudgeted replans and forgets cleared plans", () => { - const schedule = new TodoSchedule(); + const schedule = createSchedule(); const t = timer(); schedule.onUpdate([todo("a", "in_progress", 180)], t, START); expect( diff --git a/tests/TurnTiming.test.ts b/tests/TurnTiming.test.ts index 2c138ef..8d3f1d5 100644 --- a/tests/TurnTiming.test.ts +++ b/tests/TurnTiming.test.ts @@ -44,7 +44,9 @@ describe("TurnTiming outbound decoration", () => { it("recognizes successful wire-name TodoWrite calls and combines both notices", () => { const timing = new TurnTiming(900, START); + timing.recordTodoUpdate("todo_write", [todo("in_progress")], START); timing.append([output("todo_write")], [todo("in_progress")], START); + timing.recordTodoUpdate("todo_write", [todo("completed")], START + 480_000); const wire = timing.append( [output("todo_write"), output("read")], [todo("completed")], @@ -57,11 +59,13 @@ describe("TurnTiming outbound decoration", () => { it("does not start schedule tracking from a failed todo update", () => { const timing = new TurnTiming(900, START); + timing.recordTodoUpdate("todo_write", [todo("in_progress")], START); timing.append( [output("todo_write", { error: true })], [todo("in_progress")], START, ); + timing.recordTodoUpdate("todo_write", [todo("completed")], START + 60_000); const wire = timing.append( [output("todo_write")], [todo("completed")],