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:
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user