From aa0884d51b70dac525cbb6f486c28bd6cb212907 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 21 Jul 2026 20:18:22 +0000 Subject: [PATCH] fix(todo): prevented stale batch state replay - Stopped asynchronous tool-result handling from overwriting newer todo state. - Added an AgentSession regression for six exclusive completion calls. Fixes #6148 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/session/agent-session.ts | 3 +- .../test/agent-session-event-order.test.ts | 67 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 75904e901..cc70586a0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed parallel `todo done` calls losing completions when asynchronous session event handling replayed stale result snapshots over newer tool state ([#6148](https://github.com/can1357/oh-my-pi/issues/6148)). + ## [17.0.6] - 2026-07-20 - Fixed failed plan-mode exits leaving the session on the restored execution model while plan mode remained active and silently changing ambient `xd://` tool presentation; rollback now restores the plan model, thinking level, and exact top-level-versus-mounted tool partition so exit can be retried safely ([#6013](https://github.com/can1357/oh-my-pi/pull/6013)). diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 28f4ee31d..a06893da0 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -4828,9 +4828,10 @@ export class AgentSession { if (toolName === "edit" && editedPath) { this.#invalidateFileCacheForPath(editedPath); } + // TodoTool commits its state during execute. Replaying the result after + // awaited event fan-out can overwrite a newer call from the same batch. const phases = details?.phases; if (toolName === "todo" && !isError && details && Array.isArray(phases) && phases.every(isTodoPhase)) { - this.setTodoPhases(phases); if (this.#isTodoInitResult(details, toolCallId)) { this.#scheduleReplanTitleRefresh(); } diff --git a/packages/coding-agent/test/agent-session-event-order.test.ts b/packages/coding-agent/test/agent-session-event-order.test.ts index 52f4c9343..d145cbe80 100644 --- a/packages/coding-agent/test/agent-session-event-order.test.ts +++ b/packages/coding-agent/test/agent-session-event-order.test.ts @@ -16,6 +16,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; +import type { ToolCall } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -24,6 +25,7 @@ import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/ex import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TodoTool } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; describe("AgentSession subscriber event order", () => { @@ -124,4 +126,69 @@ describe("AgentSession subscriber event order", () => { expect(endIndex).toBeGreaterThan(startIndex); } }); + + it("preserves every completion from a batch of exclusive todo calls", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled test model to exist"); + + const tasks = ["one", "two", "three", "four", "five", "six"]; + const calls: ToolCall[] = tasks.map((task, index) => ({ + type: "toolCall", + id: `todo-${index}`, + name: "todo", + arguments: { op: "done", task }, + })); + const mock = createMockModel({ + responses: [{ content: calls }, { content: ["Done"] }], + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.enabled": false, + "tools.xdev": false, + }); + settings.setModelRole("default", `${model.provider}/${model.id}`); + const todo = new TodoTool({ + cwd: tempDir.path(), + hasUI: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + settings, + getTodoPhases: () => session?.getTodoPhases() ?? [], + setTodoPhases: phases => { + session?.setTodoPhases(phases); + }, + }); + const agent = new Agent({ + getApiKey: agentModel => `${agentModel.provider}-test-key`, + initialState: { + model, + systemPrompt: ["Test"], + tools: [todo], + messages: [], + }, + streamFn: (streamModel, context, options) => mock.stream(streamModel, context, options), + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + session.setTodoPhases([ + { + name: "Execution", + tasks: tasks.map((content, index) => ({ + content, + status: index === 0 ? "in_progress" : "pending", + })), + }, + ]); + + await session.prompt("Complete every task"); + await session.waitForIdle(); + const toolResults = agent.state.messages.filter(message => message.role === "toolResult"); + expect(toolResults.map(result => result.toolName)).toEqual(tasks.map(() => "todo")); + + expect(session.getTodoPhases()[0]?.tasks.map(task => task.status)).toEqual(tasks.map(() => "completed")); + }); });