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
This commit is contained in:
roboomp
2026-07-21 20:18:22 +00:00
parent 89d6a8f6d1
commit aa0884d51b
3 changed files with 73 additions and 1 deletions
+4
View File
@@ -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)).
@@ -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();
}
@@ -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"));
});
});