span:last-child]:!text-muted-foreground/40 [&_.todo-list-view_[data-todo-incomplete]>span:last-child]:line-through"
- : ""
- }`
- }
- >
+
{isActive &&
isLive &&
normalizedToolStatus &&
@@ -229,7 +214,6 @@ export const RoundContent = memo(function RoundContent(props: {
todo.status !== "completed");
- const shouldKeepTodoOpen =
- isTodo && (Boolean(isRunning) || !result || Boolean(result.isError) || hasIncompleteTodo);
- const shouldCloseCompletedTodo =
- isTodo && Boolean(result && !result.isError) && todoItems.length > 0 && !hasIncompleteTodo;
const isAskUser = !isRedactedToolContent && item.toolCall.name === ASK_USER_QUESTION_TOOL_NAME;
const askDetails = isAskUser ? parseAskUserQuestionResultDetails(result?.details) : null;
// 参数生成完毕(桌面端仅在 onToolCall 后才发 tool_call 事件)才渲染卡片;
@@ -79,7 +63,7 @@ function ToolCallItem({
? askDetails.questions
: sanitizeAskUserQuestionItems(item.toolCall.arguments?.questions)
: [];
- // 提问卡运行期强制展开等待作答;应答落定后自动收起(同 Todo 完成收起)。
+ // 提问卡运行期强制展开等待作答;应答落定后自动收起。
const shouldKeepAskOpen = !readOnly && isAskUser && (Boolean(isRunning) || !result);
const shouldCloseAnsweredAsk = isAskUser && Boolean(result);
// 权威应答截止时间:桌面端在网关上报的工具参数上盖章,倒计时与桌面计时
@@ -105,10 +89,7 @@ function ToolCallItem({
readToolApprovalPending(item.toolCall.arguments);
const shouldAutoOpen =
!isRedactedToolContent &&
- (item.toolCall.name === "Image" ||
- builtinResultKind === "display_image" ||
- shouldKeepTodoOpen ||
- shouldKeepAskOpen);
+ (item.toolCall.name === "Image" || builtinResultKind === "display_image" || shouldKeepAskOpen);
const [open, setOpen] = useState(readOnly || isRedactedToolContent ? false : shouldAutoOpen);
const isSubagentCard = isSubagentCardToolCall(item.toolCall);
const hasArgs = Object.keys(item.toolCall.arguments || {}).length > 0;
@@ -117,7 +98,6 @@ function ToolCallItem({
!isRedactedToolContent &&
!isAskUser &&
(!isSubagentCard || !result) &&
- (item.toolCall.name !== "TodoWrite" || !result) &&
(isStreamingFilePreviewTool ? !result : hasArgs);
const isBash = item.toolCall.name === "Bash";
const isManagedProcess = item.toolCall.name === "ManagedProcess";
@@ -145,31 +125,25 @@ function ToolCallItem({
);
const meta = getToolMeta(item.toolCall.name);
const ToolIcon = meta.Icon;
- const title =
- item.toolCall.name === "TodoWrite"
- ? { name: t("chat.tool.todoTitle"), action: "" }
- : isAskUser
- ? { name: t("chat.tool.askUserTitle"), action: "" }
- : isRedactedToolContent
- ? { name: getToolDisplayName(item.toolCall.name), action: "" }
- : getToolDisplayTitle(item.toolCall);
+ const title = isAskUser
+ ? { name: t("chat.tool.askUserTitle"), action: "" }
+ : isRedactedToolContent
+ ? { name: getToolDisplayName(item.toolCall.name), action: "" }
+ : getToolDisplayTitle(item.toolCall);
- const statusLabel =
- isTodo && hasIncompleteTodo && isAborted
- ? t("chat.tool.aborted")
- : isApprovalPending
- ? t("chat.toolApproval.waitingStatus")
- : isRunning
- ? isAskUser
- ? askQuestions.length > 0
- ? t("chat.askUser.waiting")
- : t("chat.askUser.preparing")
- : t("chat.tool.running")
- : result
- ? result.isError
- ? t("chat.tool.failed")
- : t("chat.tool.success")
- : t("chat.tool.waiting");
+ const statusLabel = isApprovalPending
+ ? t("chat.toolApproval.waitingStatus")
+ : isRunning
+ ? isAskUser
+ ? askQuestions.length > 0
+ ? t("chat.askUser.waiting")
+ : t("chat.askUser.preparing")
+ : t("chat.tool.running")
+ : result
+ ? result.isError
+ ? t("chat.tool.failed")
+ : t("chat.tool.success")
+ : t("chat.tool.waiting");
const statusTextClass = result?.isError
? "text-[hsl(var(--chat-error))]"
@@ -177,22 +151,14 @@ function ToolCallItem({
useEffect(() => {
if (readOnly || isRedactedToolContent) return;
- if (shouldKeepTodoOpen || shouldKeepAskOpen) {
+ if (shouldKeepAskOpen) {
setOpen(true);
- } else if (shouldCloseCompletedTodo || shouldCloseAnsweredAsk) {
+ } else if (shouldCloseAnsweredAsk) {
setOpen(false);
} else if (shouldAutoOpen) {
setOpen(true);
}
- }, [
- isRedactedToolContent,
- readOnly,
- shouldAutoOpen,
- shouldCloseAnsweredAsk,
- shouldCloseCompletedTodo,
- shouldKeepAskOpen,
- shouldKeepTodoOpen,
- ]);
+ }, [isRedactedToolContent, readOnly, shouldAutoOpen, shouldCloseAnsweredAsk, shouldKeepAskOpen]);
const canExpand =
!isRedactedToolContent &&
@@ -293,7 +259,7 @@ function ToolCallItem({
{/* 提问卡自带应答态展示;仅参数校验失败(无 details)时回落默认错误区。 */}
{result && (!isAskUser || !askDetails) ? (
@@ -417,6 +383,5 @@ export const MemoToolCallItem = memo(
previousProps.isRunning === nextProps.isRunning &&
previousProps.readOnly === nextProps.readOnly &&
previousProps.redactToolContent === nextProps.redactToolContent &&
- previousProps.isAborted === nextProps.isAborted &&
areToolTraceItemsEqual(previousProps.item, nextProps.item),
);
diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolResultDisplay.tsx b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolResultDisplay.tsx
index 57e99fc10..d1ae23b9d 100644
--- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolResultDisplay.tsx
+++ b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolResultDisplay.tsx
@@ -1,6 +1,5 @@
import { EditDiffView } from "@liveagent/ui/components/chat/EditDiffView";
import { FileToolArgsDisplay } from "@liveagent/ui/components/chat/FileToolArgs";
-import { sanitizeTodoItems, TodoListView } from "@liveagent/ui/components/chat/TodoListView";
import {
type MetaTag,
MetaTags,
@@ -39,7 +38,6 @@ import type {
ReadPdfResultDetails,
ReadTextResultDetails,
SkillsManagerResultDetails,
- TodoWriteResultDetails,
WriteResultDetails,
} from "../../../lib/tools/builtinTypes";
import {
@@ -229,12 +227,6 @@ export function ToolArgsDisplay({ item }: { item: ToolTraceItem }) {
return ;
}
- // TodoWrite args ARE the checklist — render them with the same view as the
- // result instead of dumping raw JSON (shown only until the result lands).
- if (toolCall.name === "TodoWrite") {
- return ;
- }
-
const display = getToolDisplay(toolCall);
if (isSubagentCardToolCall(toolCall)) {
@@ -451,11 +443,6 @@ export function ToolResultDisplay({
);
}
- if (kind === "todo_write") {
- const details = result.details as TodoWriteResultDetails;
- return ;
- }
-
if (kind === "read_text") {
const details = result.details as ReadTextResultDetails;
return (
diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolTraceGroup.tsx b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolTraceGroup.tsx
index bbb6d79dc..7d533e1ea 100644
--- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolTraceGroup.tsx
+++ b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolTraceGroup.tsx
@@ -60,15 +60,8 @@ function ToolTraceGroupInner(props: {
runningToolCallIds?: string[];
readOnly?: boolean;
redactToolContent?: boolean;
- isAborted?: boolean;
}) {
- const {
- items,
- runningToolCallIds = [],
- readOnly = false,
- redactToolContent = false,
- isAborted = false,
- } = props;
+ const { items, runningToolCallIds = [], readOnly = false, redactToolContent = false } = props;
const { t } = useLocale();
const counts = useMemo(
() => getToolGroupCounts(items, runningToolCallIds),
@@ -89,7 +82,6 @@ function ToolTraceGroupInner(props: {
return item ? (
previous.readOnly === next.readOnly &&
previous.redactToolContent === next.redactToolContent &&
- previous.isAborted === next.isAborted &&
previous.items.length === next.items.length &&
previous.items.every(
(item, index) =>
diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/assistantBubbleUtils.ts b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/assistantBubbleUtils.ts
index 2e0bf2b12..3cfe3fad8 100644
--- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/assistantBubbleUtils.ts
+++ b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/assistantBubbleUtils.ts
@@ -73,7 +73,9 @@ export function getToolMeta(name: string): {
return { Icon: Search, accent: "var(--tool-search-accent)", category: "search" };
case "List":
return { Icon: FolderTree, accent: "var(--tool-list-accent)", category: "list" };
- case "TodoWrite":
+ case "TaskCreate":
+ case "TaskUpdate":
+ case "TaskList":
return { Icon: ListChecks, accent: "var(--tool-list-accent)", category: "system" };
case "AskUserQuestion":
return { Icon: CircleHelp, accent: "var(--tool-list-accent)", category: "system" };
@@ -277,7 +279,9 @@ export function groupRoundBlocks(blocks: UiRound["blocks"]): GroupedRoundBlock[]
flushPendingSearches();
if (
block.item.toolCall.name === "Image" ||
- block.item.toolCall.name === "TodoWrite" ||
+ block.item.toolCall.name === "TaskCreate" ||
+ block.item.toolCall.name === "TaskUpdate" ||
+ block.item.toolCall.name === "TaskList" ||
block.item.toolCall.name === "AskUserQuestion" ||
isAgentToolName(block.item.toolCall.name)
) {
@@ -348,7 +352,9 @@ export function isBuiltinShareToolName(name: string) {
"SkillsManager",
"SSHManager",
"SshManager",
- "TodoWrite",
+ "TaskCreate",
+ "TaskUpdate",
+ "TaskList",
"TunnelManager",
"Write",
].includes(trimmed);
diff --git a/crates/agent-gateway/web/test/assistant-bubble-utils.test.mjs b/crates/agent-gateway/web/test/assistant-bubble-utils.test.mjs
index 2ea63eb28..6ab9d2a29 100644
--- a/crates/agent-gateway/web/test/assistant-bubble-utils.test.mjs
+++ b/crates/agent-gateway/web/test/assistant-bubble-utils.test.mjs
@@ -35,7 +35,7 @@ test("ordinary tool activity keeps one group identity as later tools append", ()
});
test("special tool result updates preserve their direct activity identity", () => {
- for (const name of ["TodoWrite", "AskUserQuestion", "Image", "Agent"]) {
+ for (const name of ["TaskCreate", "TaskUpdate", "TaskList", "AskUserQuestion", "Image", "Agent"]) {
const pendingItem = {
toolCall: { type: "toolCall", id: `call-${name}`, name, arguments: {} },
};
@@ -71,13 +71,13 @@ test("hosted search activity keeps one group identity as later searches append",
assert.equal(appended[0].key, first[0].key);
});
-test("TodoWrite stays standalone so transcript filtering cannot hide ordinary tools", () => {
+test("task tools stay standalone so transcript filtering cannot hide ordinary tools", () => {
const tool = (id, name) => ({
kind: "tool",
item: { toolCall: { type: "toolCall", id, name, arguments: {} } },
});
const grouped = groupRoundBlocks([
- tool("todo-1", "TodoWrite"),
+ tool("task-1", "TaskCreate"),
tool("read-1", "Read"),
tool("read-2", "Read"),
]);
@@ -86,7 +86,7 @@ test("TodoWrite stays standalone so transcript filtering cannot hide ordinary to
grouped.map((block) => block.kind),
["tool", "toolGroup"],
);
- assert.equal(grouped[0].item.toolCall.name, "TodoWrite");
+ assert.equal(grouped[0].item.toolCall.name, "TaskCreate");
assert.deepEqual(
grouped[1].items.map((item) => item.toolCall.name),
["Read", "Read"],
diff --git a/crates/agent-gateway/web/test/task-progress-indicator.test.mjs b/crates/agent-gateway/web/test/task-progress-indicator.test.mjs
index 938fae109..9ed1ba252 100644
--- a/crates/agent-gateway/web/test/task-progress-indicator.test.mjs
+++ b/crates/agent-gateway/web/test/task-progress-indicator.test.mjs
@@ -96,17 +96,37 @@ function createIndicatorHarness() {
}
function createSnapshot(overrides = {}) {
- const todos =
- overrides.todos ??
+ const tasks =
+ overrides.tasks ??
[
- { content: "Inspect", status: "completed", activeForm: "Inspecting" },
- { content: "Implement", status: "in_progress", activeForm: "Implementing" },
- { content: "Verify", status: "pending", activeForm: "Verifying" },
+ {
+ id: "1",
+ subject: "Inspect",
+ description: "Inspect completion criteria",
+ status: "completed",
+ activeForm: "Inspecting",
+ },
+ {
+ id: "2",
+ subject: "Implement",
+ description: "Implement completion criteria",
+ status: "in_progress",
+ activeForm: "Implementing",
+ },
+ {
+ id: "3",
+ subject: "Verify",
+ description: "Verify completion criteria",
+ status: "pending",
+ activeForm: "Verifying",
+ },
];
return {
- todos,
+ runId: "run-1",
+ revision: 3,
+ tasks,
completedCount: 1,
- totalCount: todos.length,
+ totalCount: tasks.length,
currentStep: 2,
state: "in_progress",
...overrides,
@@ -199,7 +219,15 @@ test("web renders props-only copy, progress semantics, and an absolute reduced-m
test("web keeps task labels stable and scopes transition motion to the changed row status", () => {
const indicator = createIndicatorHarness();
const runningSnapshot = createSnapshot({
- todos: [{ content: "Stable task", status: "in_progress", activeForm: "Changing label" }],
+ tasks: [
+ {
+ id: "stable",
+ subject: "Stable task",
+ description: "Stable completion criteria",
+ status: "in_progress",
+ activeForm: "Changing label",
+ },
+ ],
completedCount: 0,
totalCount: 1,
currentStep: 1,
@@ -220,7 +248,15 @@ test("web keeps task labels stable and scopes transition motion to the changed r
const completedTree = indicator.render({
snapshot: createSnapshot({
- todos: [{ content: "Stable task", status: "completed", activeForm: "Changed again" }],
+ tasks: [
+ {
+ id: "stable",
+ subject: "Stable task",
+ description: "Stable completion criteria",
+ status: "completed",
+ activeForm: "Changed again",
+ },
+ ],
completedCount: 1,
totalCount: 1,
currentStep: 1,
@@ -303,7 +339,15 @@ test("web Escape closes while touch clicks toggle", () => {
test("web shows pending, paused, and completed states without auto-dismissing completion", () => {
const indicator = createIndicatorHarness();
const pending = createSnapshot({
- todos: [{ content: "Wait", status: "pending", activeForm: "Waiting" }],
+ tasks: [
+ {
+ id: "wait",
+ subject: "Wait",
+ description: "Wait completion criteria",
+ status: "pending",
+ activeForm: "Waiting",
+ },
+ ],
completedCount: 0,
totalCount: 1,
currentStep: 1,
@@ -315,11 +359,17 @@ test("web shows pending, paused, and completed states without auto-dismissing co
/Paused/,
);
- const completedTodos = [
- { content: "Done", status: "completed", activeForm: "Finishing" },
+ const completedTasks = [
+ {
+ id: "done",
+ subject: "Done",
+ description: "Done completion criteria",
+ status: "completed",
+ activeForm: "Finishing",
+ },
];
const completed = createSnapshot({
- todos: completedTodos,
+ tasks: completedTasks,
completedCount: 1,
totalCount: 1,
currentStep: 1,
diff --git a/crates/agent-gateway/web/test/task-progress-sequence.test.mjs b/crates/agent-gateway/web/test/task-progress-sequence.test.mjs
deleted file mode 100644
index 33faaeed1..000000000
--- a/crates/agent-gateway/web/test/task-progress-sequence.test.mjs
+++ /dev/null
@@ -1,381 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-
-import { fileURLToPath } from "node:url";
-
-import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs";
-
-const rootDir = fileURLToPath(new URL("../", import.meta.url));
-
-function createHookHarness() {
- const states = [];
- const refs = [];
- const effects = [];
- let stateIndex = 0;
- let refIndex = 0;
- let effectIndex = 0;
- let pendingEffects = [];
-
- const react = {
- useState(initialValue) {
- const index = stateIndex++;
- if (!(index in states)) {
- states[index] = typeof initialValue === "function" ? initialValue() : initialValue;
- }
- return [
- states[index],
- (next) => {
- states[index] = typeof next === "function" ? next(states[index]) : next;
- },
- ];
- },
- useRef(initialValue) {
- const index = refIndex++;
- if (!(index in refs)) refs[index] = { current: initialValue };
- return refs[index];
- },
- useEffect(effect, dependencies) {
- const index = effectIndex++;
- const previous = effects[index];
- const changed =
- !previous ||
- dependencies.length !== previous.dependencies.length ||
- dependencies.some((dependency, dependencyIndex) => !Object.is(dependency, previous.dependencies[dependencyIndex]));
- if (changed) pendingEffects.push({ index, effect, dependencies });
- },
- };
-
- return {
- react,
- render(run) {
- stateIndex = 0;
- refIndex = 0;
- effectIndex = 0;
- pendingEffects = [];
- const value = run();
- const scheduled = pendingEffects;
- pendingEffects = [];
- for (const entry of scheduled) {
- effects[entry.index]?.cleanup?.();
- effects[entry.index] = {
- dependencies: entry.dependencies,
- cleanup: entry.effect() ?? undefined,
- };
- }
- return value;
- },
- unmount() {
- for (const effect of effects) effect?.cleanup?.();
- },
- };
-}
-
-function installFakeWindow() {
- const previousWindow = globalThis.window;
- const timers = new Map();
- const delays = [];
- let nextId = 1;
- globalThis.window = {
- setTimeout(callback, delay) {
- const id = nextId++;
- timers.set(id, callback);
- delays.push(delay);
- return id;
- },
- clearTimeout(id) {
- timers.delete(id);
- },
- };
- return {
- delays,
- get size() {
- return timers.size;
- },
- runNext() {
- const next = timers.entries().next().value;
- assert.ok(next, "expected a queued sequence timer");
- const [id, callback] = next;
- timers.delete(id);
- callback();
- },
- restore() {
- if (previousWindow === undefined) delete globalThis.window;
- else globalThis.window = previousWindow;
- },
- };
-}
-
-function snapshot(completedCount) {
- const todos = [
- { content: "One", activeForm: "Working one", status: completedCount >= 1 ? "completed" : "in_progress" },
- {
- content: "Two",
- activeForm: "Working two",
- status: completedCount >= 2 ? "completed" : completedCount === 1 ? "in_progress" : "pending",
- },
- { content: "Three", activeForm: "Working three", status: completedCount >= 2 ? "in_progress" : "pending" },
- ];
- return {
- todos,
- completedCount,
- totalCount: todos.length,
- currentStep: Math.min(completedCount + 1, todos.length),
- state: "in_progress",
- };
-}
-
-function snapshotFromTodos(todos) {
- const completedCount = todos.filter((todo) => todo.status === "completed").length;
- const inProgressIndex = todos.findIndex((todo) => todo.status === "in_progress");
- const pendingIndex = todos.findIndex((todo) => todo.status === "pending");
- return {
- todos,
- completedCount,
- totalCount: todos.length,
- currentStep:
- inProgressIndex >= 0 ? inProgressIndex + 1 : pendingIndex >= 0 ? pendingIndex + 1 : todos.length,
- state:
- completedCount === todos.length
- ? "completed"
- : inProgressIndex >= 0
- ? "in_progress"
- : "pending",
- };
-}
-
-const update = (key, completedCount) => ({ key, snapshot: snapshot(completedCount) });
-
-test("Web sequencer presents batched real updates one at a time and ignores persistence handoff", () => {
- const fakeWindow = installFakeWindow();
- const hooks = createHookHarness();
- const { TASK_PROGRESS_SEQUENCE_STEP_MS, useSequencedTaskProgress } = createWebModuleLoader({ rootDir,
- mocks: { react: hooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts");
- const initial = [update("todo-0", 0)];
- const batch = [...initial, update("todo-1", 1), update("todo-2", 2)];
-
- try {
- assert.equal(hooks.render(() => useSequencedTaskProgress(initial)).completedCount, 0);
- assert.equal(hooks.render(() => useSequencedTaskProgress(batch)).completedCount, 0);
- assert.equal(hooks.render(() => useSequencedTaskProgress(batch)).completedCount, 1);
- assert.deepEqual(fakeWindow.delays, [TASK_PROGRESS_SEQUENCE_STEP_MS]);
-
- fakeWindow.runNext();
- assert.equal(hooks.render(() => useSequencedTaskProgress(batch)).completedCount, 2);
- assert.equal(fakeWindow.size, 0);
-
- const duplicateSnapshot = [
- ...batch,
- { key: "anonymous-live-overlap", snapshot: snapshot(2) },
- ];
- assert.equal(hooks.render(() => useSequencedTaskProgress(duplicateSnapshot)).completedCount, 2);
- assert.equal(hooks.render(() => useSequencedTaskProgress(duplicateSnapshot)).completedCount, 2);
- assert.equal(fakeWindow.size, 0);
-
- assert.equal(hooks.render(() => useSequencedTaskProgress(initial)).completedCount, 2);
- assert.equal(hooks.render(() => useSequencedTaskProgress(batch)).completedCount, 2);
- assert.equal(fakeWindow.size, 0);
- } finally {
- hooks.unmount();
- fakeWindow.restore();
- }
-});
-
-test("Web sequencer keeps the initial roster stable through shorter updates and history restore", () => {
- const fakeWindow = installFakeWindow();
- const hooks = createHookHarness();
- const { useSequencedTaskProgress } = createWebModuleLoader({ rootDir,
- mocks: { react: hooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts");
- const initialTodos = Array.from({ length: 12 }, (_, index) => ({
- content: `Task ${index + 1}`,
- activeForm: `Working ${index + 1}`,
- status: index === 0 ? "in_progress" : "pending",
- }));
- const initial = [{ key: "plan", snapshot: snapshotFromTodos(initialTodos) }];
- const shortened = {
- key: "status-1",
- snapshot: snapshotFromTodos(
- initialTodos.slice(0, 5).map((todo) => ({ ...todo, status: "completed" })),
- ),
- };
- const batch = [...initial, shortened];
-
- try {
- assert.equal(hooks.render(() => useSequencedTaskProgress(initial)).totalCount, 12);
- assert.equal(hooks.render(() => useSequencedTaskProgress(batch)).completedCount, 0);
- const displayed = hooks.render(() => useSequencedTaskProgress(batch));
- assert.equal(displayed.totalCount, 12);
- assert.equal(displayed.completedCount, 5);
- assert.deepEqual(
- displayed.todos.map((todo) => todo.content),
- initialTodos.map((todo) => todo.content),
- );
- assert.equal(fakeWindow.size, 0);
-
- const restoredHooks = createHookHarness();
- const restoredHook = createWebModuleLoader({ rootDir,
- mocks: { react: restoredHooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts").useSequencedTaskProgress;
- const restored = restoredHooks.render(() => restoredHook(batch, false));
- assert.equal(restored.totalCount, 12);
- assert.equal(restored.completedCount, 5);
- assert.equal(fakeWindow.size, 0);
- restoredHooks.unmount();
- } finally {
- hooks.unmount();
- fakeWindow.restore();
- }
-});
-
-test("Web sequencer skips restored history replay, applies same-call changes, and clears immediately", () => {
- const fakeWindow = installFakeWindow();
- const hooks = createHookHarness();
- const { useSequencedTaskProgress } = createWebModuleLoader({ rootDir,
- mocks: { react: hooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts");
- const restored = [update("todo-0", 0), update("todo-1", 1), update("todo-2", 2)];
-
- try {
- assert.equal(hooks.render(() => useSequencedTaskProgress(restored)).completedCount, 2);
- assert.equal(fakeWindow.size, 0);
-
- const hydrationHooks = createHookHarness();
- const hydrationHook = createWebModuleLoader({ rootDir,
- mocks: { react: hydrationHooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts").useSequencedTaskProgress;
- assert.equal(hydrationHooks.render(() => hydrationHook([], false)), null);
- assert.equal(hydrationHooks.render(() => hydrationHook(restored, false)), null);
- assert.equal(hydrationHooks.render(() => hydrationHook(restored, false)).completedCount, 2);
- assert.equal(fakeWindow.size, 0);
- hydrationHooks.unmount();
-
- const revised = [{ key: "todo-2", snapshot: snapshot(1) }];
- const replacementHooks = createHookHarness();
- const replacementHook = createWebModuleLoader({ rootDir,
- mocks: { react: replacementHooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts").useSequencedTaskProgress;
- assert.equal(replacementHooks.render(() => replacementHook(revised)).completedCount, 1);
- const sameCallUpdated = [{ key: "todo-2", snapshot: snapshot(2) }];
- assert.equal(replacementHooks.render(() => replacementHook(sameCallUpdated)).completedCount, 1);
- assert.equal(replacementHooks.render(() => replacementHook(sameCallUpdated)).completedCount, 2);
- replacementHooks.unmount();
-
- const cleared = [...restored, { key: "todo-clear", snapshot: null }];
- assert.equal(hooks.render(() => useSequencedTaskProgress(cleared)), null);
- assert.equal(hooks.render(() => useSequencedTaskProgress(cleared)), null);
- assert.equal(fakeWindow.size, 0);
- } finally {
- hooks.unmount();
- fakeWindow.restore();
- }
-});
-
-test("Web sequencer clears on a new user-turn boundary and starts the next plan fresh", () => {
- const fakeWindow = installFakeWindow();
- const hooks = createHookHarness();
- const { useSequencedTaskProgress } = createWebModuleLoader({
- rootDir,
- mocks: { react: hooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts");
- const oldPlan = [update("old-todo", 2)];
- const boundary = [{ key: "user-turn:next", snapshot: null }];
- const nextPlan = [...boundary, update("new-todo", 0)];
-
- try {
- assert.equal(hooks.render(() => useSequencedTaskProgress(oldPlan)).completedCount, 2);
- assert.equal(hooks.render(() => useSequencedTaskProgress(boundary)), null);
- assert.equal(hooks.render(() => useSequencedTaskProgress(boundary)), null);
- assert.equal(hooks.render(() => useSequencedTaskProgress(nextPlan)), null);
- assert.equal(hooks.render(() => useSequencedTaskProgress(nextPlan)).completedCount, 0);
- assert.equal(fakeWindow.size, 0);
- } finally {
- hooks.unmount();
- fakeWindow.restore();
- }
-});
-
-test("Web sequencer keeps partial argument frames hidden until the TodoWrite result settles", () => {
- const fakeWindow = installFakeWindow();
- const hooks = createHookHarness();
- const { TASK_PROGRESS_ARGUMENT_STABLE_MS, useSequencedTaskProgress } =
- createWebModuleLoader({
- rootDir,
- mocks: { react: hooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts");
- const boundary = [{ key: "user-turn:new", snapshot: null }];
- const draft = (todos) => [
- ...boundary,
- { key: "todo-live", snapshot: snapshotFromTodos(todos), settled: false },
- ];
- const invalidDraft = [
- ...boundary,
- { key: "todo-live", snapshot: undefined, settled: false },
- ];
- const fullTodos = Array.from({ length: 12 }, (_, index) => ({
- content: `Task ${index + 1}`,
- activeForm: `Working ${index + 1}`,
- status: index === 0 ? "in_progress" : "pending",
- }));
-
- try {
- assert.equal(hooks.render(() => useSequencedTaskProgress(boundary)), null);
-
- assert.equal(hooks.render(() => useSequencedTaskProgress(draft(fullTodos.slice(0, 1)))), null);
- assert.equal(fakeWindow.size, 1);
- assert.equal(fakeWindow.delays.at(-1), TASK_PROGRESS_ARGUMENT_STABLE_MS);
-
- assert.equal(hooks.render(() => useSequencedTaskProgress(invalidDraft)), null);
- assert.equal(fakeWindow.size, 0);
-
- assert.equal(hooks.render(() => useSequencedTaskProgress(draft(fullTodos.slice(0, 4)))), null);
- assert.equal(fakeWindow.size, 1);
- assert.equal(hooks.render(() => useSequencedTaskProgress(invalidDraft)), null);
- assert.equal(fakeWindow.size, 0);
-
- const settled = [
- ...boundary,
- { key: "todo-live", snapshot: snapshotFromTodos(fullTodos), settled: true },
- ];
- assert.equal(hooks.render(() => useSequencedTaskProgress(settled)), null);
- const displayed = hooks.render(() => useSequencedTaskProgress(settled));
- assert.equal(displayed.totalCount, 12);
- assert.equal(fakeWindow.size, 0);
- } finally {
- hooks.unmount();
- fakeWindow.restore();
- }
-});
-
-test("Web sequencer adopts a stable complete-arguments fallback when no result arrives", () => {
- const fakeWindow = installFakeWindow();
- const hooks = createHookHarness();
- const { useSequencedTaskProgress } = createWebModuleLoader({
- rootDir,
- mocks: { react: hooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts");
- const boundary = [{ key: "user-turn:fallback", snapshot: null }];
- const todos = Array.from({ length: 12 }, (_, index) => ({
- content: `Fallback ${index + 1}`,
- activeForm: `Working fallback ${index + 1}`,
- status: index === 0 ? "in_progress" : "pending",
- }));
- const completeArguments = [
- ...boundary,
- { key: "todo-fallback", snapshot: snapshotFromTodos(todos), settled: false },
- ];
-
- try {
- assert.equal(hooks.render(() => useSequencedTaskProgress(boundary)), null);
- assert.equal(hooks.render(() => useSequencedTaskProgress(completeArguments)), null);
- assert.equal(fakeWindow.size, 1);
- fakeWindow.runNext();
- assert.equal(
- hooks.render(() => useSequencedTaskProgress(completeArguments)).totalCount,
- 12,
- );
- } finally {
- hooks.unmount();
- fakeWindow.restore();
- }
-});
diff --git a/crates/agent-gateway/web/test/task-progress.test.mjs b/crates/agent-gateway/web/test/task-progress.test.mjs
index 6254b48ff..15bf7a3e3 100644
--- a/crates/agent-gateway/web/test/task-progress.test.mjs
+++ b/crates/agent-gateway/web/test/task-progress.test.mjs
@@ -5,101 +5,83 @@ import { fileURLToPath } from "node:url";
import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs";
const rootDir = fileURLToPath(new URL("../", import.meta.url));
-const taskProgress = createWebModuleLoader({ rootDir }).loadModule("@liveagent/ui/lib/chat/taskProgress.ts");
-const todo = (content, status, activeForm = content) => ({ content, status, activeForm });
-const block = ({
- todos,
+const taskProgress = createWebModuleLoader({ rootDir }).loadModule(
+ "@liveagent/ui/lib/chat/taskProgress.ts",
+);
+const task = (id, subject, status, activeForm = subject) => ({
id,
+ subject,
+ description: `${subject} completion criteria`,
+ activeForm,
+ status,
+});
+const block = ({
+ id = "task-call",
+ name = "TaskUpdate",
+ tasks = [],
+ runId = "run-1",
+ revision = 1,
settled = true,
isError = false,
- resultKind = "todo_write",
- resultTodos = todos,
+ kind = "task_list",
}) => ({
kind: "tool",
item: {
- toolCall: { id, name: "TodoWrite", arguments: { todos } },
+ toolCall: { id, name, arguments: { taskId: "1", status: "completed" } },
toolResult: settled
- ? { isError, details: { kind: resultKind, todos: resultTodos } }
+ ? { isError, details: { kind, action: "updated", runId, revision, tasks } }
: undefined,
},
});
+const assistantRow = (blocks) => ({ kind: "assistant", rounds: [{ blocks }] });
-test("web projection prefers successful result details and summarizes progress", () => {
- const resultTodos = [
- todo("Inspect", "completed"),
- todo("Implement", "in_progress", "Working"),
+test("WebUI mirrors the latest successful canonical task snapshot", () => {
+ const tasks = [
+ task("1", "Inspect", "completed", "Inspecting"),
+ task("2", "Implement", "in_progress", "Implementing"),
];
- const rows = [
- {
- kind: "assistant",
- rounds: [
- {
- blocks: [block({ todos: [todo("Stale", "pending")], resultTodos })],
- },
- ],
- },
- ];
- const snapshot = taskProgress.selectLatestTodoProgress(rows);
- assert.deepEqual(snapshot.todos, resultTodos);
+ const snapshot = taskProgress.selectLatestTaskProgress([
+ assistantRow([block({ name: "TaskCreate", tasks: tasks.slice(0, 1) })]),
+ assistantRow([block({ tasks, revision: 2 })]),
+ ]);
+
+ assert.deepEqual(snapshot.tasks, tasks);
assert.deepEqual(
- [snapshot.completedCount, snapshot.totalCount, snapshot.currentStep, snapshot.state],
- [1, 2, 2, "in_progress"],
+ [snapshot.runId, snapshot.revision, snapshot.completedCount, snapshot.currentStep, snapshot.state],
+ ["run-1", 2, 1, 2, "in_progress"],
);
});
-test("web projection mirrors streaming, failure, and clear semantics", () => {
- const live = [todo("Live", "in_progress", "Working live")];
- const stable = [todo("Inspect", "completed"), todo("Implement", "in_progress", "Working")];
- const rows = [
- { kind: "assistant", rounds: [{ blocks: [block({ todos: stable })] }] },
- {
- kind: "assistant",
- rounds: [{ blocks: [block({ todos: [{ content: "Partial" }], settled: false }), block({ todos: [todo("Failed", "pending")], isError: true })] }],
- },
- ];
- assert.deepEqual(taskProgress.selectLatestTodoProgress(rows).todos, stable);
- assert.deepEqual(
- taskProgress.selectLatestTodoProgress(rows, [{ blocks: [block({ todos: live, settled: false })] }]).todos,
- live,
- );
- rows.push({ kind: "assistant", rounds: [{ blocks: [block({ todos: [] })] }] });
- assert.equal(taskProgress.selectLatestTodoProgress(rows), null);
+test("WebUI ignores provisional, failed, and malformed task data", () => {
+ const stable = [task("1", "Stable", "in_progress", "Working")];
+ const snapshot = taskProgress.selectLatestTaskProgress([
+ assistantRow([block({ tasks: stable })]),
+ assistantRow([
+ block({ id: "partial", settled: false, tasks: [task("2", "Partial", "pending")] }),
+ block({ id: "failed", isError: true, tasks: [task("2", "Failed", "pending")] }),
+ block({ id: "wrong", kind: "other", tasks: [task("2", "Wrong", "pending")] }),
+ ]),
+ ]);
+ assert.deepEqual(snapshot.tasks, stable);
});
-test("web projection distinguishes tentative, invalid, and settled TodoWrite frames", () => {
- const boundary = { kind: "user", key: "new-turn" };
- const oneTodo = [todo("Task 1", "in_progress")];
- const twelveTodos = Array.from({ length: 12 }, (_, index) =>
- todo(`Task ${index + 1}`, index === 0 ? "in_progress" : "pending"),
+test("WebUI clears the previous run at a user boundary", () => {
+ const oldTasks = [task("1", "Old", "completed")];
+ assert.equal(
+ taskProgress.selectLatestTaskProgress([
+ assistantRow([block({ tasks: oldTasks })]),
+ { kind: "user", key: "new-run" },
+ ]),
+ null,
);
- const rowsWith = (todoBlock) => [
- boundary,
- { kind: "assistant", rounds: [{ blocks: [todoBlock] }] },
- ];
-
- const tentative = taskProgress.selectTodoProgressUpdates(
- rowsWith(block({ id: "todo-live", todos: oneTodo, settled: false })),
- ).at(-1);
- assert.equal(tentative.settled, false);
- assert.equal(tentative.snapshot.totalCount, 1);
-
- const invalid = taskProgress.selectTodoProgressUpdates(
- rowsWith(block({ id: "todo-live", todos: [{ content: "Partial" }], settled: false })),
- ).at(-1);
- assert.equal(invalid.settled, false);
- assert.equal(invalid.snapshot, undefined);
-
- const settled = taskProgress.selectTodoProgressUpdates(
- rowsWith(block({ id: "todo-live", todos: twelveTodos })),
- ).at(-1);
- assert.equal(settled.settled, true);
- assert.equal(settled.snapshot.totalCount, 12);
});
-test("web transcript hides TodoWrite blocks while preserving ordinary tools", () => {
- assert.equal(taskProgress.isTodoWriteToolBlock(block({ todos: [], settled: false })), true);
+test("WebUI hides all task tool blocks while preserving ordinary tools", () => {
+ for (const name of ["TaskCreate", "TaskUpdate", "TaskList"]) {
+ assert.equal(taskProgress.isTaskToolBlock(block({ name, settled: false })), true);
+ }
assert.equal(
- taskProgress.isTodoWriteToolBlock({
+ taskProgress.isTaskToolBlock({
kind: "tool",
item: { toolCall: { name: "Read", arguments: { path: "README.md" } } },
}),
@@ -109,185 +91,14 @@ test("web transcript hides TodoWrite blocks while preserving ordinary tools", ()
fileURLToPath(new URL("../src/pages/chat/assistant-bubble/RoundContent.tsx", import.meta.url)),
"utf8",
);
- assert.match(source, /groupedBlocks\.filter\(\(block\) => !isTodoWriteToolBlock\(block\)\)/);
- assert.doesNotMatch(source, /latestTodoItem/);
- const appSource = readFileSync(
- fileURLToPath(new URL("../src/app/GatewayApp.tsx", import.meta.url)),
- "utf8",
- );
- assert.match(appSource, /selectTodoProgressUpdates\(transcriptRows\)/);
- assert.match(appSource, /useSequencedTaskProgress\(updates, isConversationRunning\)/);
- assert.match(appSource, /key=\{displayedConversationId\}/);
-});
-
-test("web projection keeps real TodoWrite updates ordered across live-history overlap", () => {
- const first = [todo("One", "in_progress", "Working one"), todo("Two", "pending")];
- const second = [todo("One", "completed"), todo("Two", "in_progress", "Working two")];
- const updates = taskProgress.selectTodoProgressUpdates(
- [
- {
- kind: "assistant",
- rounds: [{ blocks: [block({ id: "todo-1", todos: first })] }],
- },
- ],
- [
- {
- blocks: [
- block({ id: "todo-1", todos: first }),
- block({ id: "todo-2", todos: second }),
- ],
- },
- ],
- );
- assert.deepEqual(
- updates.map((update) => [update.key, update.snapshot.completedCount]),
- [
- ["todo-1", 0],
- ["todo-2", 1],
- ],
- );
-});
-
-test("web projection hides the old plan on a submitted user turn until a new TodoWrite", () => {
- const oldTodos = [todo("Old task", "completed")];
- const oldBlock = block({ id: "old-todo", todos: oldTodos });
- const hiddenUpdates = taskProgress.selectTodoProgressUpdates(
- [
- { kind: "assistant", rounds: [{ blocks: [oldBlock] }] },
- { kind: "user", key: "next-message" },
- ],
- [{ blocks: [oldBlock] }],
- );
-
- assert.deepEqual(
- hiddenUpdates.map((update) => [update.key, update.snapshot]),
- [["user-turn:next-message", null]],
- );
- assert.equal(
- taskProgress.selectLatestTodoProgress([
- { kind: "assistant", rounds: [{ blocks: [oldBlock] }] },
- { kind: "user", key: "next-message" },
- ]),
- null,
- );
-
- const newTodos = [todo("New task", "in_progress", "Working new task")];
- const resumedUpdates = taskProgress.selectTodoProgressUpdates([
- { kind: "assistant", rounds: [{ blocks: [oldBlock] }] },
- { kind: "user", key: "next-message" },
- {
- kind: "assistant",
- rounds: [{ blocks: [block({ id: "new-todo", todos: newTodos })] }],
- },
- ]);
- const resumedPlan = taskProgress.foldTodoProgressUpdates(resumedUpdates);
- assert.deepEqual(
- resumedUpdates.map((update) => update.key),
- ["user-turn:next-message", "new-todo"],
- );
- assert.deepEqual(resumedPlan.snapshot.todos, newTodos);
+ assert.match(source, /groupedBlocks\.filter\(\(block\) => !isTaskToolBlock\(block\)\)/);
});
-test("web projection ignores invalid settled results instead of falling back to arguments", () => {
- const stable = [todo("Stable", "in_progress", "Working")];
- const replacement = [todo("Untrusted", "pending")];
- const rows = [
- { kind: "assistant", rounds: [{ blocks: [block({ todos: stable })] }] },
- {
- kind: "assistant",
- rounds: [
- {
- blocks: [
- block({ todos: replacement, resultKind: "unexpected" }),
- block({ todos: replacement, resultTodos: [{ content: "Partial" }] }),
- ],
- },
- ],
- },
- ];
- assert.deepEqual(taskProgress.selectLatestTodoProgress(rows).todos, stable);
-});
-
-test("web projection locks the confirmed plan roster while later calls merge only task statuses", () => {
- const initialTodos = Array.from({ length: 12 }, (_, index) =>
- todo(`Task ${index + 1}`, index === 0 ? "in_progress" : "pending", `Working ${index + 1}`),
- );
- const initialSnapshot = taskProgress.createTodoProgressSnapshot(initialTodos);
- let plan = taskProgress.applyTodoProgressUpdate(
- { anchorKey: null, snapshot: null },
- { key: "initial-plan", snapshot: initialSnapshot },
- );
-
- const shorterUpdate = taskProgress.createTodoProgressSnapshot(
- initialTodos.slice(0, 5).map((item) => ({ ...item, status: "completed" })),
- );
- plan = taskProgress.applyTodoProgressUpdate(plan, {
- key: "status-update-1",
- snapshot: shorterUpdate,
- });
-
- assert.equal(plan.snapshot.totalCount, 12);
- assert.equal(plan.snapshot.completedCount, 5);
- assert.deepEqual(
- plan.snapshot.todos.map((item) => item.content),
- initialTodos.map((item) => item.content),
- );
-
- const rewrittenFullUpdate = taskProgress.createTodoProgressSnapshot(
- initialTodos.map((item, index) =>
- todo(
- `Rewritten ${index + 1}`,
- index < 5 ? "completed" : index === 5 ? "in_progress" : "pending",
- ),
- ),
- );
- plan = taskProgress.applyTodoProgressUpdate(plan, {
- key: "status-update-2",
- snapshot: rewrittenFullUpdate,
- });
-
- assert.equal(plan.snapshot.totalCount, 12);
- assert.equal(plan.snapshot.currentStep, 6);
- assert.equal(plan.snapshot.todos[5].status, "in_progress");
- assert.deepEqual(
- plan.snapshot.todos.map((item) => item.content),
- initialTodos.map((item) => item.content),
- );
-});
-
-test("web projection lets the anchor finish its roster, then empty starts the next plan", () => {
- const provisional = taskProgress.createTodoProgressSnapshot([
- todo("One", "in_progress"),
- todo("Two", "pending"),
- ]);
- const confirmed = taskProgress.createTodoProgressSnapshot([
- todo("One", "in_progress"),
- todo("Two", "pending"),
- todo("Three", "pending"),
- ]);
- const nextPlan = taskProgress.createTodoProgressSnapshot([todo("Fresh", "pending")]);
- const plan = taskProgress.foldTodoProgressUpdates([
- { key: "initial-plan", snapshot: provisional },
- { key: "initial-plan", snapshot: confirmed },
- { key: "clear", snapshot: null },
- { key: "next-plan", snapshot: nextPlan },
- ]);
-
- assert.equal(plan.anchorKey, "next-plan");
- assert.deepEqual(plan.snapshot.todos, nextPlan.todos);
-});
-
-test("web projection rejects duplicate running items and reports the completed final step", () => {
- assert.equal(
- taskProgress.readCompleteTodoList([
- todo("One", "in_progress"),
- todo("Two", "in_progress"),
- ]),
- null,
+test("WebUI app selects a snapshot directly without a sequencing compatibility layer", () => {
+ const source = readFileSync(
+ fileURLToPath(new URL("../src/app/GatewayApp.tsx", import.meta.url)),
+ "utf8",
);
- const snapshot = taskProgress.createTodoProgressSnapshot([
- todo("One", "completed"),
- todo("Two", "completed"),
- ]);
- assert.deepEqual([snapshot.completedCount, snapshot.currentStep, snapshot.state], [2, 2, "completed"]);
+ assert.match(source, /selectLatestTaskProgress\(transcriptRows\)/);
+ assert.match(source, /key=\{displayedConversationId\}/);
});
diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs
index 94a1a5949..b376e612b 100644
--- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs
+++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs
@@ -325,32 +325,44 @@ pub async fn chat_history_upsert_active_segment(
}
pub(crate) async fn chat_history_append_segment_inner(
- input: ChatHistorySegmentMutationInput,
+ input: ChatHistoryAppendSegmentInput,
) -> Result {
tauri::async_runtime::spawn_blocking(move || {
- validate_segment_mutation_input(&input)?;
let mut conn = open_db()?;
- let tx = conn
- .transaction()
- .map_err(|e| format!("开启 append segment 事务失败:{e}"))?;
-
- validate_append_segment_preconditions(&tx, &input)?;
- upsert_chat_history_header(&tx, &input.conversation)?;
- insert_single_segment(&tx, input.conversation.id.trim(), &input.segment)?;
- verify_chat_history_consistency(&tx, input.conversation.id.trim())?;
-
- tx.commit()
- .map_err(|e| format!("提交 append segment 事务失败:{e}"))?;
-
+ append_chat_history_segment_sync(&mut conn, &input)?;
get_summary_by_id(&conn, input.conversation.id.trim())
})
.await
.map_err(|e| format!("chat_history_append_segment join 失败:{e}"))?
}
+fn append_chat_history_segment_sync(
+ conn: &mut Connection,
+ input: &ChatHistoryAppendSegmentInput,
+) -> Result<(), String> {
+ validate_append_segment_input(input)?;
+ let tx = conn
+ .transaction()
+ .map_err(|e| format!("开启 append segment 事务失败:{e}"))?;
+
+ validate_append_segment_preconditions(&tx, input)?;
+ upsert_chat_history_header(&tx, &input.conversation)?;
+ upsert_single_segment(
+ &tx,
+ input.conversation.id.trim(),
+ &input.previous_segment,
+ )?;
+ insert_single_segment(&tx, input.conversation.id.trim(), &input.segment)?;
+ verify_chat_history_consistency(&tx, input.conversation.id.trim())?;
+
+ tx.commit()
+ .map_err(|e| format!("提交 append segment 事务失败:{e}"))?;
+ Ok(())
+}
+
#[tauri::command]
pub async fn chat_history_append_segment(
- input: ChatHistorySegmentMutationInput,
+ input: ChatHistoryAppendSegmentInput,
gateway_controller: tauri::State<'_, Arc>,
) -> Result {
let summary = chat_history_append_segment_inner(input).await?;
diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/segments.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/segments.rs
index 63460fcb6..79d7224b5 100644
--- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/segments.rs
+++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/segments.rs
@@ -105,9 +105,22 @@ fn validate_segment_mutation_input(input: &ChatHistorySegmentMutationInput) -> R
Ok(())
}
+fn validate_append_segment_input(input: &ChatHistoryAppendSegmentInput) -> Result<(), String> {
+ validate_conversation_input(&input.conversation)?;
+ validate_segment_input(&input.previous_segment)?;
+ validate_segment_input(&input.segment)?;
+ if input.segment.segment_index != input.conversation.active_segment_index {
+ return Err("segmentIndex 必须等于 activeSegmentIndex".to_string());
+ }
+ if input.previous_segment.segment_index + 1 != input.segment.segment_index {
+ return Err("previousSegment 与 segment 必须连续".to_string());
+ }
+ Ok(())
+}
+
fn validate_append_segment_preconditions(
conn: &Connection,
- input: &ChatHistorySegmentMutationInput,
+ input: &ChatHistoryAppendSegmentInput,
) -> Result<(), String> {
let conversation_id = input.conversation.id.trim();
let existing_header = conn
@@ -138,6 +151,12 @@ fn validate_append_segment_preconditions(
if active_segment_index != total_segment_count - 1 {
return Err("append segment 前置校验失败:现有 activeSegmentIndex 非最后一段".to_string());
}
+ if input.previous_segment.segment_index != active_segment_index {
+ return Err(format!(
+ "append segment 待封存分段错误:期望 segmentIndex={},实际为 {}",
+ active_segment_index, input.previous_segment.segment_index
+ ));
+ }
if input.segment.segment_index != total_segment_count {
return Err(format!(
"append segment 只能追加到末尾:期望 segmentIndex={},实际为 {}",
@@ -177,6 +196,23 @@ fn validate_append_segment_preconditions(
));
}
+ let stored_previous_segment_id = conn
+ .query_row(
+ "
+ SELECT segment_id
+ FROM chatHistorySegment
+ WHERE conversation_id = ?1 AND segment_index = ?2
+ ",
+ params![conversation_id, active_segment_index],
+ |row| row.get::<_, String>(0),
+ )
+ .optional()
+ .map_err(|e| format!("读取待封存历史分段失败:{e}"))?
+ .ok_or_else(|| "append segment 缺少待封存的现有活跃分段".to_string())?;
+ if stored_previous_segment_id != input.previous_segment.segment_id {
+ return Err("append segment 待封存分段身份不一致".to_string());
+ }
+
Ok(())
}
diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs
index 9d1fc26c0..001c2587c 100644
--- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs
+++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs
@@ -1081,6 +1081,81 @@ mod tests {
);
}
+ #[test]
+ fn append_checkpoint_atomically_flushes_finalized_segment_before_adding_next_segment() {
+ let mut conn = open_test_db().expect("open test db");
+ let initial_conversation = sample_conversation();
+ upsert_chat_history_header(&conn, &initial_conversation).expect("upsert initial header");
+ upsert_single_segment(
+ &conn,
+ "conv-1",
+ &ChatHistorySegmentInput {
+ segment_index: 0,
+ segment_id: "segment-0".to_string(),
+ summary_json: None,
+ messages_json:
+ r#"[{"id":"m-user","role":"user","content":"start","timestamp":1}]"#
+ .to_string(),
+ message_count: 1,
+ start_message_id: Some("m-user".to_string()),
+ end_message_id: Some("m-user".to_string()),
+ created_at: 1,
+ updated_at: 1,
+ },
+ )
+ .expect("seed active segment");
+
+ let mut checkpoint_conversation = initial_conversation;
+ checkpoint_conversation.context_meta_json = r#"{"activeSegmentIndex":1,"totalSegmentCount":2,"totalMessageCount":2}"#.to_string();
+ checkpoint_conversation.active_segment_index = 1;
+ checkpoint_conversation.total_segment_count = 2;
+ checkpoint_conversation.total_message_count = 2;
+ checkpoint_conversation.updated_at = 3;
+ append_chat_history_segment_sync(
+ &mut conn,
+ &ChatHistoryAppendSegmentInput {
+ conversation: checkpoint_conversation,
+ previous_segment: ChatHistorySegmentInput {
+ segment_index: 0,
+ segment_id: "segment-0".to_string(),
+ summary_json: None,
+ messages_json: r#"[
+ {"id":"m-user","role":"user","content":"start","timestamp":1},
+ {"id":"m-tool","role":"toolResult","toolName":"Read","toolCallId":"call-1","content":"result","timestamp":2}
+ ]"#
+ .to_string(),
+ message_count: 2,
+ start_message_id: Some("m-user".to_string()),
+ end_message_id: Some("m-tool".to_string()),
+ created_at: 1,
+ updated_at: 2,
+ },
+ segment: ChatHistorySegmentInput {
+ segment_index: 1,
+ segment_id: "segment-1".to_string(),
+ summary_json: Some(r#"{"role":"summary","content":"checkpoint"}"#.to_string()),
+ messages_json: "[]".to_string(),
+ message_count: 0,
+ start_message_id: None,
+ end_message_id: None,
+ created_at: 3,
+ updated_at: 3,
+ },
+ },
+ )
+ .expect("append checkpoint");
+
+ let record = get_record_by_id(&conn, "conv-1").expect("load checkpointed history");
+ assert_eq!(record.active_segment_index, 1);
+ assert_eq!(record.total_segment_count, 2);
+ assert_eq!(record.total_message_count, 2);
+ let segments = load_segments(&conn, "conv-1").expect("load checkpointed segments");
+ assert_eq!(segments.len(), 2);
+ assert_eq!(segments[0].message_count, 2);
+ assert_eq!(segments[1].message_count, 0);
+ assert!(segments[1].summary_json.is_some());
+ }
+
#[test]
fn chat_history_time_overview_query_falls_back_to_time_window() {
let conn = open_test_db().expect("open test db");
diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/types.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/types.rs
index 69f538ca4..fed32e018 100644
--- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/types.rs
+++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/types.rs
@@ -190,6 +190,14 @@ pub struct ChatHistorySegmentMutationInput {
pub segment: ChatHistorySegmentInput,
}
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ChatHistoryAppendSegmentInput {
+ pub conversation: ChatHistoryConversationInput,
+ pub previous_segment: ChatHistorySegmentInput,
+ pub segment: ChatHistorySegmentInput,
+}
+
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatHistorySearchArgs {
diff --git a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs
index 334f5430d..ad6bbd648 100644
--- a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs
+++ b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs
@@ -1063,7 +1063,9 @@ fn is_builtin_share_tool_name(name: &str) -> bool {
| "SkillsManager"
| "SSHManager"
| "SshManager"
- | "TodoWrite"
+ | "TaskCreate"
+ | "TaskUpdate"
+ | "TaskList"
| "TunnelManager"
| "Write"
)
diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts
index c6a3673c5..b3708e41f 100644
--- a/crates/agent-gui/src/i18n/config.ts
+++ b/crates/agent-gui/src/i18n/config.ts
@@ -304,15 +304,12 @@ export const translations: Record> = {
"chat.tool.running": "运行中",
"chat.tool.failed": "失败",
"chat.tool.success": "已完成",
- "chat.tool.aborted": "已中止",
"chat.tool.waiting": "等待",
"chat.tool.command": "命令",
"chat.tool.args": "参数",
"chat.tool.return": "返回",
"chat.tool.error": "(错误)",
"chat.tool.viewReturn": "查看返回内容",
- "chat.tool.todoTitle": "任务清单",
- "chat.tool.todoEmpty": "暂无任务",
"chat.taskProgress.title": "任务进度",
"chat.taskProgress.step": "第 {current} / {total} 步",
"chat.taskProgress.running": "运行中",
@@ -1407,10 +1404,17 @@ export const translations: Record> = {
"settings.builtinTool.send_message.desc": "与子代理之间收发消息",
"settings.builtinTool.send_message.detail":
"在主对话与子代理之间传递消息,用于协调多代理协作。需要子代理运行时;仅在对话场景注册。",
- "settings.builtinTool.todo_write.name": "任务清单",
- "settings.builtinTool.todo_write.desc": "创建与更新当前会话的任务清单",
- "settings.builtinTool.todo_write.detail":
- "让模型在处理多步骤任务时列出任务清单并逐项推进状态,进度以清单卡片实时展示在对话中。清单仅保存在当前对话内,不跨对话保留;仅在对话场景注册。",
+ "settings.builtinTool.task_create.name": "创建任务",
+ "settings.builtinTool.task_create.desc": "向当前运行添加一个任务",
+ "settings.builtinTool.task_create.detail": "创建带有稳定数字 ID 的持久任务;仅在对话场景注册。",
+ "settings.builtinTool.task_update.name": "更新任务",
+ "settings.builtinTool.task_update.desc": "按稳定 ID 更新一个任务",
+ "settings.builtinTool.task_update.detail":
+ "更新任务状态或内容,不替换整个任务清单;仅在对话场景注册。",
+ "settings.builtinTool.task_list.name": "查看任务",
+ "settings.builtinTool.task_list.desc": "读取当前运行的完整任务清单",
+ "settings.builtinTool.task_list.detail":
+ "返回当前运行的权威任务快照与稳定 ID;仅在对话场景注册。",
"settings.builtinTool.ask_user_question.name": "用户提问",
"settings.builtinTool.ask_user_question.desc": "以选项卡片向你提问并等待选择",
"settings.builtinTool.ask_user_question.detail":
@@ -2610,15 +2614,12 @@ export const translations: Record> = {
"chat.tool.running": "Running",
"chat.tool.failed": "Failed",
"chat.tool.success": "Completed",
- "chat.tool.aborted": "Aborted",
"chat.tool.waiting": "Waiting",
"chat.tool.command": "Command",
"chat.tool.args": "Args",
"chat.tool.return": "Return",
"chat.tool.error": "(Error)",
"chat.tool.viewReturn": "View Return",
- "chat.tool.todoTitle": "Task list",
- "chat.tool.todoEmpty": "No tasks yet",
"chat.taskProgress.title": "Task progress",
"chat.taskProgress.step": "Step {current} of {total}",
"chat.taskProgress.running": "Running",
@@ -3758,10 +3759,18 @@ export const translations: Record> = {
"settings.builtinTool.send_message.desc": "Exchange messages with subagents",
"settings.builtinTool.send_message.detail":
"Relays messages between the main conversation and subagents to coordinate multi-agent work. Requires the subagent runtime; chat sessions only.",
- "settings.builtinTool.todo_write.name": "Task List",
- "settings.builtinTool.todo_write.desc": "Create and update a task list for the current session",
- "settings.builtinTool.todo_write.detail":
- "Lets the model plan multi-step work as a task list and advance each item's status as it goes, shown as a live checklist card in the conversation. The list lives only in the current conversation and is not carried across conversations; chat sessions only.",
+ "settings.builtinTool.task_create.name": "Create Task",
+ "settings.builtinTool.task_create.desc": "Add one task to the current run",
+ "settings.builtinTool.task_create.detail":
+ "Create a durable task with a stable numeric ID; chat sessions only.",
+ "settings.builtinTool.task_update.name": "Update Task",
+ "settings.builtinTool.task_update.desc": "Update one task by stable ID",
+ "settings.builtinTool.task_update.detail":
+ "Update task status or content without replacing the task list; chat sessions only.",
+ "settings.builtinTool.task_list.name": "List Tasks",
+ "settings.builtinTool.task_list.desc": "Read the current run's complete task list",
+ "settings.builtinTool.task_list.detail":
+ "Return the authoritative task snapshot and stable IDs for the current run; chat sessions only.",
"settings.builtinTool.ask_user_question.name": "Ask User",
"settings.builtinTool.ask_user_question.desc":
"Ask you multiple-choice questions in a card and wait for your selections",
diff --git a/crates/agent-gui/src/lib/chat/compaction/controller.ts b/crates/agent-gui/src/lib/chat/compaction/controller.ts
index 77db0d8d1..99a9fcef3 100644
--- a/crates/agent-gui/src/lib/chat/compaction/controller.ts
+++ b/crates/agent-gui/src/lib/chat/compaction/controller.ts
@@ -45,7 +45,7 @@ export type CompactionSinks = {
publishStatus?: (status: CompactionStatus) => void;
setBridgeToolStatus?: (status: string | null, isCompaction?: boolean) => void;
queueCheckpoint?: (state: ConversationViewState) => void;
- persist?: (state: ConversationViewState) => Promise;
+ persist?: (state: ConversationViewState) => Promise;
restoreComposer?: (
composerText: string | undefined,
uploadedFiles: PendingUploadedFile[],
@@ -127,6 +127,13 @@ export class CompactionController {
return { compactionsApplied: this.pressure.compactionsApplied };
}
+ private async persistCheckpoint(binding: CompactionTurnBinding, state: ConversationViewState) {
+ const persisted = await binding.sinks.persist?.(state);
+ if (persisted === false) {
+ throw new Error("compaction checkpoint persistence failed");
+ }
+ }
+
beginRequest(context: Context, state: ConversationViewState) {
this.ledger.rebase(context);
this.updateTurnMeta(state);
@@ -206,7 +213,7 @@ export class CompactionController {
complete: binding.complete,
});
- await binding.sinks.persist?.(outcome.state);
+ await this.persistCheckpoint(binding, outcome.state);
this.rollbackSnapshot = null;
const appliedState = presend.composeAppliedState(outcome.state);
binding.sinks.applyState?.(appliedState);
@@ -330,7 +337,7 @@ export class CompactionController {
complete: binding.complete,
});
- await binding.sinks.persist?.(outcome.state);
+ await this.persistCheckpoint(binding, outcome.state);
this.rollbackSnapshot = null;
binding.sinks.applyStateMidRun?.(outcome.state);
this.settleCompleted(params.trigger, outcome.newSegmentIndex);
diff --git a/crates/agent-gui/src/lib/chat/conversation/conversationState.ts b/crates/agent-gui/src/lib/chat/conversation/conversationState.ts
index 720c04a03..24e2718db 100644
--- a/crates/agent-gui/src/lib/chat/conversation/conversationState.ts
+++ b/crates/agent-gui/src/lib/chat/conversation/conversationState.ts
@@ -1,6 +1,7 @@
import type { AssistantMessage, Context, Message } from "@earendil-works/pi-ai";
import { createUuid } from "@liveagent/ui/lib/shared/id";
import { assistantMessageToText } from "../../providers/llm";
+import type { TaskListState } from "../../tools/builtinTypes";
import {
type FileLedger,
formatFileLedgerBlock,
@@ -72,6 +73,7 @@ export type StoredChatContextMeta = {
activeSegmentIndex: number;
totalSegmentCount: number;
totalMessageCount: number;
+ taskList?: TaskListState;
};
export type StoredContextSegment = {
@@ -381,6 +383,7 @@ function buildConversationMeta(params: {
activeSegmentIndex?: number;
totalSegmentCount?: number;
totalMessageCount?: number;
+ taskList?: TaskListState;
}): StoredChatContextMeta {
const activeSegmentArrayIndex =
typeof params.activeSegmentIndex === "number"
@@ -397,6 +400,7 @@ function buildConversationMeta(params: {
params.totalSegmentCount ??
Math.max(params.segments.length, activeSegmentIndex + (params.segments.length > 0 ? 1 : 0)),
totalMessageCount: params.totalMessageCount ?? countMessages(params.segments),
+ taskList: params.taskList,
};
}
@@ -985,6 +989,7 @@ export function normalizeConversationState(input: {
input.meta.totalMessageCount !== undefined
? Math.max(0, input.meta.totalMessageCount - droppedMessageCount)
: countMessages(segments),
+ taskList: input.meta.taskList,
});
const transcript =
input.transcript ??
@@ -1123,6 +1128,7 @@ export function appendMessagesToConversation(
(normalizedSegments[activeSegmentIndex]?.segmentIndex ?? 0) + 1,
),
totalMessageCount: state.meta.totalMessageCount + appendedMessageCount,
+ taskList: state.meta.taskList,
});
const items = updateTimelineForAppend({
previousItems: state.transcript.items,
@@ -1259,6 +1265,7 @@ export function replaceActiveSegmentMessages(
activeSegmentIndex: state.activeSegmentIndex,
totalSegmentCount: state.meta.totalSegmentCount,
totalMessageCount: state.meta.totalMessageCount - previousMessageCount + messages.length,
+ taskList: state.meta.taskList,
});
const activeStartMessageIndex = getTranscriptSegmentStart(state.transcript, activeSegment);
const items = rebuildTimelineForActiveSegment({
@@ -1286,3 +1293,25 @@ export function replaceActiveSegmentMessages(
},
};
}
+
+export function setTaskListState(
+ state: ConversationViewState,
+ taskList: TaskListState,
+): ConversationViewState {
+ return {
+ ...state,
+ meta: {
+ ...state.meta,
+ taskList,
+ },
+ };
+}
+
+export function clearTaskListState(state: ConversationViewState): ConversationViewState {
+ if (!state.meta.taskList) return state;
+ const { taskList: _taskList, ...meta } = state.meta;
+ return {
+ ...state,
+ meta,
+ };
+}
diff --git a/crates/agent-gui/src/lib/chat/history/chatHistory.ts b/crates/agent-gui/src/lib/chat/history/chatHistory.ts
index 5c5e05863..78bd639ae 100644
--- a/crates/agent-gui/src/lib/chat/history/chatHistory.ts
+++ b/crates/agent-gui/src/lib/chat/history/chatHistory.ts
@@ -1,5 +1,6 @@
import type { Message } from "@earendil-works/pi-ai";
import { invoke } from "@tauri-apps/api/core";
+import { parseTaskListState } from "../../tools/taskState";
import { normalizeConversationSystemPrompt } from "../context/systemPrompt";
import {
type ConversationViewState,
@@ -76,6 +77,12 @@ type ChatHistorySegmentWireRecord = {
updatedAt: number;
};
+type ChatHistoryAppendSegmentInput = {
+ conversation: ChatHistoryConversationInput;
+ previousSegment: ChatHistorySegmentWireRecord;
+ segment: ChatHistorySegmentWireRecord;
+};
+
type ChatHistorySegmentWindowWireRecord = {
segmentIndex: number;
segmentId: string;
@@ -233,9 +240,21 @@ function parseStoredChatContextMeta(
activeSegmentIndex: counts.activeSegmentIndex,
totalSegmentCount: counts.totalSegmentCount,
totalMessageCount: counts.totalMessageCount,
+ taskList: parseStoredTaskListState(parsed.taskList),
};
}
+function parseStoredTaskListState(value: unknown) {
+ if (value === undefined) return undefined;
+ try {
+ return parseTaskListState(value);
+ } catch (error) {
+ // 任务清单是辅助运行态:损坏数据只丢弃清单本身,绝不能让整个会话窗口打不开。
+ console.warn("忽略无法解析的历史任务清单状态", error);
+ return undefined;
+ }
+}
+
export async function listChatHistory(
page: number,
pageSize: number,
@@ -450,7 +469,7 @@ async function upsertChatHistoryActiveSegmentRaw(input: ChatHistorySegmentMutati
return invoke("chat_history_upsert_active_segment", { input });
}
-async function appendChatHistorySegmentRaw(input: ChatHistorySegmentMutationInput) {
+async function appendChatHistorySegmentRaw(input: ChatHistoryAppendSegmentInput) {
return invoke("chat_history_append_segment", { input });
}
@@ -554,8 +573,18 @@ async function writeConversationRuntime(
}
if (activeSegment.segmentIndex === cursor.activeSegmentIndex + 1) {
+ const previousSegment = state.segments.find(
+ (segment) => segment.segmentIndex === cursor.activeSegmentIndex,
+ );
+ if (!previousSegment) {
+ throw new Error("追加历史分段时缺少待封存的上一活跃分段");
+ }
+ if (previousSegment.segmentId !== cursor.activeSegmentId) {
+ throw new Error("待封存历史分段身份与持久化游标不一致");
+ }
return appendChatHistorySegmentRaw({
conversation,
+ previousSegment: buildChatHistorySegmentInput(previousSegment),
segment: buildChatHistorySegmentInput(activeSegment),
});
}
diff --git a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts
index 6f9d4aed4..a73a1db22 100644
--- a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts
+++ b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts
@@ -169,7 +169,9 @@ export function buildToolsSuffix(
if (has("SendMessage")) toolGroups.push("subagent message bus (SendMessage)");
if (has("Bash")) toolGroups.push("the command tool (Bash)");
if (has("ManagedProcess")) toolGroups.push("managed local processes (ManagedProcess)");
- if (has("TodoWrite")) toolGroups.push("task planning checklist (TodoWrite)");
+ if (hasAny("TaskCreate", "TaskUpdate", "TaskList")) {
+ toolGroups.push("durable task planning (TaskCreate / TaskUpdate / TaskList)");
+ }
if (hasDynamicMcp) toolGroups.push("MCP business tools whose names are prefixed with mcp_");
const sections: string[] = [];
@@ -366,15 +368,14 @@ export function buildToolsSuffix(
);
}
- if (has("TodoWrite")) {
+ if (hasAny("TaskCreate", "TaskUpdate", "TaskList")) {
sections.push(
[
- "## Task Planning (TodoWrite)",
- "- Proactively use TodoWrite for multi-step tasks (3+ distinct steps) or when the user gives multiple tasks; skip it for a single trivial action.",
- "- Every call replaces the entire list — pass the complete, current set of todos each time, not a delta.",
- "- Exactly one item may have status=in_progress at a time; mark it in_progress before starting, and immediately (not batched) mark it completed as soon as it is done, before starting the next.",
- '- content is the imperative/declarative form ("Run tests"); activeForm is the present-continuous form shown only while in_progress ("Running tests").',
- "- Keep each item specific and actionable; break vague or large items into smaller ones.",
+ "## Task Planning",
+ "- Proactively use TaskCreate for multi-step work (3+ distinct steps) or multiple user requests; skip it for one trivial action.",
+ "- Task IDs are stable and executor-assigned. Use TaskUpdate with taskId; never replace or recreate the list after context compaction.",
+ "- Exactly one task may be in_progress. Mark it in_progress before work and completed immediately after it is fully done.",
+ "- Use TaskList whenever the authoritative task state is unclear.",
].join("\n"),
);
}
diff --git a/crates/agent-gui/src/lib/subagents/run.ts b/crates/agent-gui/src/lib/subagents/run.ts
index e3e47fa48..d33ab8eef 100644
--- a/crates/agent-gui/src/lib/subagents/run.ts
+++ b/crates/agent-gui/src/lib/subagents/run.ts
@@ -490,6 +490,7 @@ export async function executeSubagentRun(
},
persist: async (state) => {
schedulePersist("running", state);
+ return undefined;
},
},
buildPreparedContext: (state) => buildRequestContext(state),
diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts
index 8369c8125..1d8a6e681 100644
--- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts
+++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts
@@ -31,8 +31,8 @@ import { createShellTools } from "./shellTools";
import type { SkillAccessPolicy } from "./skillAccessPolicy";
import { createSkillTools } from "./skillTools";
import { createSSHManagerTools, type SshManagerSessionChange } from "./sshManagerTools";
+import { createTaskTools, type TaskStateStore } from "./taskTools";
import { createTerminalTools } from "./terminalTools";
-import { createTodoTools, type TodoToolState } from "./todoTools";
import { createTunnelManagerTools, type TunnelManagerChange } from "./tunnelManagerTools";
export type BuiltinToolRegistry = {
@@ -268,21 +268,21 @@ async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryP
export async function buildBuiltinToolRegistry(
params: BuildBuiltinBaseToolRegistryParams & {
subagentRuntime?: SubagentRuntimeConfig;
- todoState?: TodoToolState;
+ taskStateStore?: TaskStateStore;
/** chat 场景注入交互式提问工具;子代理/自动化场景无人值守,不注册。 */
askUserQuestionConversationId?: string;
},
) {
const baseBundles = await buildBaseBuiltinToolBundles(params);
- const todoBundles =
- params.runtimeScope === "chat" && params.todoState
- ? [createTodoTools({ state: params.todoState })]
+ const taskBundles =
+ params.runtimeScope === "chat" && params.taskStateStore
+ ? [createTaskTools(params.taskStateStore)]
: [];
const askUserQuestionBundles =
params.runtimeScope === "chat" && params.askUserQuestionConversationId
? [createAskUserQuestionTools({ conversationId: params.askUserQuestionConversationId })]
: [];
- const chatBundles = [...todoBundles, ...askUserQuestionBundles];
+ const chatBundles = [...taskBundles, ...askUserQuestionBundles];
const subagentRuntime = params.subagentRuntime;
if (!subagentRuntime) {
diff --git a/crates/agent-gui/src/lib/tools/builtinTypes.ts b/crates/agent-gui/src/lib/tools/builtinTypes.ts
index 588d2da5d..bdfc2fd36 100644
--- a/crates/agent-gui/src/lib/tools/builtinTypes.ts
+++ b/crates/agent-gui/src/lib/tools/builtinTypes.ts
@@ -366,15 +366,30 @@ export type GrepResultDetails = {
files: GrepResultFileSummary[];
};
-export type TodoItem = {
- content: string;
- status: "pending" | "in_progress" | "completed";
+export type TaskStatus = "pending" | "in_progress" | "completed";
+
+export type TaskItem = {
+ id: string;
+ subject: string;
+ description: string;
activeForm: string;
+ status: TaskStatus;
+};
+
+export type TaskListState = {
+ runId: string;
+ revision: number;
+ nextTaskId: number;
+ tasks: TaskItem[];
};
-export type TodoWriteResultDetails = {
- kind: "todo_write";
- todos: TodoItem[];
+export type TaskListResultDetails = {
+ kind: "task_list";
+ action: "created" | "updated" | "listed";
+ runId: string;
+ revision: number;
+ tasks: TaskItem[];
+ taskId?: string;
};
export type BuiltinToolResultDetails =
@@ -395,5 +410,5 @@ export type BuiltinToolResultDetails =
| ListResultDetails
| GlobResultDetails
| GrepResultDetails
- | TodoWriteResultDetails
+ | TaskListResultDetails
| Record;
diff --git a/crates/agent-gui/src/lib/tools/taskState.ts b/crates/agent-gui/src/lib/tools/taskState.ts
new file mode 100644
index 000000000..a78ce1db0
--- /dev/null
+++ b/crates/agent-gui/src/lib/tools/taskState.ts
@@ -0,0 +1,83 @@
+import type { TaskItem, TaskListState, TaskStatus } from "./builtinTypes";
+
+const TASK_STATUSES = new Set(["pending", "in_progress", "completed"]);
+
+export function readNonEmptyString(value: unknown, path: string) {
+ if (typeof value !== "string" || !value.trim()) {
+ throw new Error(`${path} must be a non-empty string.`);
+ }
+ return value.trim();
+}
+
+function readPositiveInteger(value: unknown, path: string) {
+ if (!Number.isSafeInteger(value) || (value as number) < 1) {
+ throw new Error(`${path} must be a positive integer.`);
+ }
+ return value as number;
+}
+
+function readNonNegativeInteger(value: unknown, path: string) {
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
+ throw new Error(`${path} must be a non-negative integer.`);
+ }
+ return value as number;
+}
+
+function parseTaskItem(value: unknown, index: number): TaskItem {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error(`taskList.tasks[${index}] must be an object.`);
+ }
+ const item = value as Record;
+ const status = item.status;
+ if (typeof status !== "string" || !TASK_STATUSES.has(status as TaskStatus)) {
+ throw new Error(`taskList.tasks[${index}].status is invalid.`);
+ }
+ return {
+ id: readNonEmptyString(item.id, `taskList.tasks[${index}].id`),
+ subject: readNonEmptyString(item.subject, `taskList.tasks[${index}].subject`),
+ description: readNonEmptyString(item.description, `taskList.tasks[${index}].description`),
+ activeForm: readNonEmptyString(item.activeForm, `taskList.tasks[${index}].activeForm`),
+ status: status as TaskStatus,
+ };
+}
+
+export function parseTaskListState(value: unknown): TaskListState {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error("taskList must be an object.");
+ }
+ const candidate = value as Record;
+ if (!Array.isArray(candidate.tasks)) {
+ throw new Error("taskList.tasks must be an array.");
+ }
+ const tasks = candidate.tasks.map(parseTaskItem);
+ const ids = new Set();
+ let inProgressCount = 0;
+ for (const task of tasks) {
+ if (ids.has(task.id)) throw new Error(`taskList contains duplicate task id ${task.id}.`);
+ ids.add(task.id);
+ if (task.status === "in_progress") inProgressCount += 1;
+ }
+ if (inProgressCount > 1) {
+ throw new Error("taskList may contain at most one in_progress task.");
+ }
+ const nextTaskId = readPositiveInteger(candidate.nextTaskId, "taskList.nextTaskId");
+ for (const id of ids) {
+ const numericId = Number(id);
+ if (!Number.isSafeInteger(numericId) || numericId < 1 || numericId >= nextTaskId) {
+ throw new Error(`taskList task id ${id} is outside the allocated id range.`);
+ }
+ }
+ return {
+ runId: readNonEmptyString(candidate.runId, "taskList.runId"),
+ revision: readNonNegativeInteger(candidate.revision, "taskList.revision"),
+ nextTaskId,
+ tasks,
+ };
+}
+
+export function cloneTaskListState(state: TaskListState): TaskListState {
+ return {
+ ...state,
+ tasks: state.tasks.map((task) => ({ ...task })),
+ };
+}
diff --git a/crates/agent-gui/src/lib/tools/taskTools.ts b/crates/agent-gui/src/lib/tools/taskTools.ts
new file mode 100644
index 000000000..0efce8816
--- /dev/null
+++ b/crates/agent-gui/src/lib/tools/taskTools.ts
@@ -0,0 +1,269 @@
+import type { Tool, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
+import { Type } from "typebox";
+import {
+ type BuiltinToolBundle,
+ createBuiltinMetadataMap,
+ type TaskItem,
+ type TaskListResultDetails,
+ type TaskListState,
+ type TaskStatus,
+} from "./builtinTypes";
+import { cloneTaskListState, readNonEmptyString } from "./taskState";
+
+export type TaskStateStore = {
+ runId: string;
+ getState: () => TaskListState | undefined;
+ commitState: (state: TaskListState) => Promise;
+};
+
+const TASK_CREATE_DESCRIPTION = `Create one task in the current run's durable task list.
+
+Use TaskCreate for multi-step work, then use TaskUpdate to mark one task in_progress before starting it and completed immediately after finishing it. The executor assigns a stable numeric task ID; never invent or reuse task IDs.`;
+
+const TASK_UPDATE_DESCRIPTION = `Update one existing task by its stable taskId.
+
+Only supplied fields are changed. At most one task may be in_progress. Completed tasks remain in the list for progress reporting. Use TaskList whenever the current authoritative state is unclear.`;
+
+const TASK_LIST_DESCRIPTION = `Return the complete authoritative task list for the current run, including stable task IDs and statuses. Use it after uncertainty or context compaction; do not recreate tasks that are already present.`;
+
+const taskStatusSchema = Type.Union([
+ Type.Literal("pending"),
+ Type.Literal("in_progress"),
+ Type.Literal("completed"),
+]);
+
+const taskCreateParameters = Type.Object({
+ subject: Type.String({ description: "Short imperative task title." }),
+ description: Type.String({ description: "Detailed completion criteria for the task." }),
+ activeForm: Type.String({ description: "Present-continuous label shown while in progress." }),
+});
+
+const taskUpdateParameters = Type.Object({
+ taskId: Type.String({ description: "Stable numeric ID returned by TaskCreate or TaskList." }),
+ subject: Type.Optional(Type.String({ description: "Replacement short imperative title." })),
+ description: Type.Optional(Type.String({ description: "Replacement completion criteria." })),
+ activeForm: Type.Optional(
+ Type.String({ description: "Replacement present-continuous progress label." }),
+ ),
+ status: Type.Optional(taskStatusSchema),
+});
+
+const taskListParameters = Type.Object({});
+
+function readOptionalString(value: unknown, field: string) {
+ return value === undefined ? undefined : readNonEmptyString(value, field);
+}
+
+function readOptionalStatus(value: unknown): TaskStatus | undefined {
+ if (value === undefined) return undefined;
+ if (value !== "pending" && value !== "in_progress" && value !== "completed") {
+ throw new Error('status must be "pending", "in_progress", or "completed".');
+ }
+ return value;
+}
+
+function emptyState(runId: string): TaskListState {
+ return { runId, revision: 0, nextTaskId: 1, tasks: [] };
+}
+
+function currentState(store: TaskStateStore) {
+ const state = store.getState();
+ return state?.runId === store.runId ? cloneTaskListState(state) : undefined;
+}
+
+function resultDetails(
+ state: TaskListState,
+ action: TaskListResultDetails["action"],
+ taskId?: string,
+): TaskListResultDetails {
+ return {
+ kind: "task_list",
+ action,
+ runId: state.runId,
+ revision: state.revision,
+ tasks: state.tasks.map((task) => ({ ...task })),
+ taskId,
+ };
+}
+
+function resultText(state: TaskListState, message: string) {
+ return `${message}\n${JSON.stringify(state)}`;
+}
+
+function toolError(toolCall: ToolCall, message: string): ToolResultMessage {
+ return {
+ role: "toolResult",
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ content: [{ type: "text", text: message }],
+ details: {},
+ isError: true,
+ timestamp: Date.now(),
+ };
+}
+
+export function formatTaskListRuntimeContext(state: TaskListState | undefined) {
+ if (!state || state.tasks.length === 0) return "";
+ return [
+ "## Authoritative Task Runtime State",
+ "",
+ JSON.stringify(state),
+ "",
+ "The JSON above is the authoritative task state for this run. Context compaction does not start a new plan. Do not recreate, renumber, reorder, or replace these tasks. Use TaskCreate, TaskUpdate, and TaskList to modify or inspect them by stable taskId.",
+ ].join("\n");
+}
+
+export function createTaskTools(store: TaskStateStore): BuiltinToolBundle {
+ const tools: Tool[] = [
+ { name: "TaskCreate", description: TASK_CREATE_DESCRIPTION, parameters: taskCreateParameters },
+ { name: "TaskUpdate", description: TASK_UPDATE_DESCRIPTION, parameters: taskUpdateParameters },
+ { name: "TaskList", description: TASK_LIST_DESCRIPTION, parameters: taskListParameters },
+ ];
+ let queue: Promise = Promise.resolve();
+
+ function runSerialized(operation: () => Promise) {
+ const result = queue.then(operation, operation);
+ queue = result.then(
+ () => undefined,
+ () => undefined,
+ );
+ return result;
+ }
+
+ function executeToolCall(toolCall: ToolCall, signal?: AbortSignal) {
+ return runSerialized(async () => {
+ if (signal?.aborted) return toolError(toolCall, "Cancelled");
+ const args = (toolCall.arguments ?? {}) as Record;
+ try {
+ if (toolCall.name === "TaskCreate") {
+ const state = currentState(store) ?? emptyState(store.runId);
+ const task: TaskItem = {
+ id: String(state.nextTaskId),
+ subject: readNonEmptyString(args.subject, "subject"),
+ description: readNonEmptyString(args.description, "description"),
+ activeForm: readNonEmptyString(args.activeForm, "activeForm"),
+ status: "pending",
+ };
+ const nextState: TaskListState = {
+ ...state,
+ revision: state.revision + 1,
+ nextTaskId: state.nextTaskId + 1,
+ tasks: [...state.tasks, task],
+ };
+ await store.commitState(nextState);
+ return {
+ role: "toolResult",
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ content: [{ type: "text", text: resultText(nextState, `Created task ${task.id}.`) }],
+ details: resultDetails(nextState, "created", task.id),
+ isError: false,
+ timestamp: Date.now(),
+ };
+ }
+
+ if (toolCall.name === "TaskUpdate") {
+ const state = currentState(store);
+ if (!state) throw new Error("No task list exists for the current run.");
+ const taskId = readNonEmptyString(args.taskId, "taskId");
+ const taskIndex = state.tasks.findIndex((task) => task.id === taskId);
+ if (taskIndex < 0) throw new Error(`Task ${taskId} does not exist.`);
+ const subject = readOptionalString(args.subject, "subject");
+ const description = readOptionalString(args.description, "description");
+ const activeForm = readOptionalString(args.activeForm, "activeForm");
+ const status = readOptionalStatus(args.status);
+ if (
+ subject === undefined &&
+ description === undefined &&
+ activeForm === undefined &&
+ status === undefined
+ ) {
+ throw new Error("TaskUpdate requires at least one field to update.");
+ }
+ if (
+ status === "in_progress" &&
+ state.tasks.some((task) => task.id !== taskId && task.status === "in_progress")
+ ) {
+ throw new Error("Another task is already in_progress. Complete or pause it first.");
+ }
+ const existing = state.tasks[taskIndex] as TaskItem;
+ const updated: TaskItem = {
+ ...existing,
+ ...(subject === undefined ? {} : { subject }),
+ ...(description === undefined ? {} : { description }),
+ ...(activeForm === undefined ? {} : { activeForm }),
+ ...(status === undefined ? {} : { status }),
+ };
+ const tasks = state.tasks.slice();
+ tasks[taskIndex] = updated;
+ const nextState = { ...state, revision: state.revision + 1, tasks };
+ await store.commitState(nextState);
+ return {
+ role: "toolResult",
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ content: [{ type: "text", text: resultText(nextState, `Updated task ${taskId}.`) }],
+ details: resultDetails(nextState, "updated", taskId),
+ isError: false,
+ timestamp: Date.now(),
+ };
+ }
+
+ if (toolCall.name === "TaskList") {
+ const state = currentState(store) ?? emptyState(store.runId);
+ return {
+ role: "toolResult",
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ content: [{ type: "text", text: resultText(state, "Current task list.") }],
+ details: resultDetails(state, "listed"),
+ isError: false,
+ timestamp: Date.now(),
+ };
+ }
+
+ return toolError(toolCall, `Unknown tool: ${toolCall.name}`);
+ } catch (error) {
+ return toolError(
+ toolCall,
+ error instanceof Error ? error.message : `${toolCall.name} failed.`,
+ );
+ }
+ });
+ }
+
+ return {
+ groupId: "system",
+ tools,
+ executeToolCall,
+ metadataByName: createBuiltinMetadataMap([
+ [
+ "TaskCreate",
+ {
+ groupId: "system",
+ kind: "task_create",
+ isReadOnly: false,
+ displayCategory: "system",
+ },
+ ],
+ [
+ "TaskUpdate",
+ {
+ groupId: "system",
+ kind: "task_update",
+ isReadOnly: false,
+ displayCategory: "system",
+ },
+ ],
+ [
+ "TaskList",
+ {
+ groupId: "system",
+ kind: "task_list",
+ isReadOnly: true,
+ displayCategory: "system",
+ },
+ ],
+ ]),
+ };
+}
diff --git a/crates/agent-gui/src/lib/tools/todoTools.ts b/crates/agent-gui/src/lib/tools/todoTools.ts
deleted file mode 100644
index 432a5cabc..000000000
--- a/crates/agent-gui/src/lib/tools/todoTools.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-import type { Tool, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
-import { Type } from "typebox";
-import { type BuiltinToolBundle, createBuiltinMetadataMap, type TodoItem } from "./builtinTypes";
-
-export type TodoToolState = ReturnType;
-
-export function createTodoToolState() {
- let todos: TodoItem[] = [];
-
- return {
- getTodos(): TodoItem[] {
- return todos;
- },
- setTodos(next: TodoItem[]) {
- todos = next;
- },
- clear() {
- todos = [];
- },
- };
-}
-
-const todoStateByConversationId = new Map();
-
-export function getOrCreateTodoToolState(conversationId: string): TodoToolState {
- let state = todoStateByConversationId.get(conversationId);
- if (!state) {
- state = createTodoToolState();
- todoStateByConversationId.set(conversationId, state);
- }
- return state;
-}
-
-export function disposeTodoToolState(conversationId: string) {
- todoStateByConversationId.delete(conversationId);
-}
-
-const TODO_WRITE_TOOL_DESCRIPTION = `Create and manage a structured task list for the current session. Use this to plan multi-step work, track progress, and demonstrate thoroughness.
-
-Every call REPLACES the entire list — always pass the complete, current set of todos, not just the ones that changed.
-
-Use it when:
-- A task requires 3 or more distinct steps or actions.
-- The user provides multiple tasks (numbered or comma-separated).
-- A task is non-trivial and benefits from explicit tracking.
-- After completing a task, to mark it done and surface any newly discovered follow-up work.
-
-Skip it for a single, trivial, or purely conversational task.
-
-Rules:
-- Exactly one item may have status="in_progress" at any time.
-- Mark an item in_progress before starting it, and completed immediately after finishing it — do not batch completions.
-- Only mark an item completed when it is FULLY done; keep it in_progress if blocked, partially done, or erroring.
-- content is the imperative form ("Run tests"); activeForm is the present-continuous form shown while the item is in_progress ("Running tests").`;
-
-const TODO_ITEM_CONTENT_DESCRIPTION = 'Imperative description of the task, e.g. "Run tests".';
-const TODO_ITEM_STATUS_DESCRIPTION = "Current status of the task.";
-const TODO_ITEM_ACTIVE_FORM_DESCRIPTION =
- 'Present-continuous form shown while the task is in_progress, e.g. "Running tests".';
-
-const todoWriteParameters = Type.Object({
- todos: Type.Array(
- Type.Object({
- content: Type.String({ description: TODO_ITEM_CONTENT_DESCRIPTION }),
- status: Type.Union(
- [Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("completed")],
- { description: TODO_ITEM_STATUS_DESCRIPTION },
- ),
- activeForm: Type.String({ description: TODO_ITEM_ACTIVE_FORM_DESCRIPTION }),
- }),
- { description: "The complete, current list of todos. This replaces any previous list." },
- ),
-});
-
-function validateTodoShape(args: Record): TodoItem[] {
- const rawTodos = args.todos;
- if (!Array.isArray(rawTodos)) {
- throw new Error("TodoWrite requires a `todos` array.");
- }
- return rawTodos.map((item, index) => {
- if (!item || typeof item !== "object") {
- throw new Error(`TodoWrite todos[${index}] must be an object.`);
- }
- const candidate = item as Record;
- if (typeof candidate.content !== "string" || !candidate.content.trim()) {
- throw new Error(`TodoWrite todos[${index}].content must be a non-empty string.`);
- }
- if (
- candidate.status !== "pending" &&
- candidate.status !== "in_progress" &&
- candidate.status !== "completed"
- ) {
- throw new Error(
- `TodoWrite todos[${index}].status must be "pending", "in_progress", or "completed".`,
- );
- }
- if (typeof candidate.activeForm !== "string" || !candidate.activeForm.trim()) {
- throw new Error(`TodoWrite todos[${index}].activeForm must be a non-empty string.`);
- }
- return {
- content: candidate.content,
- status: candidate.status,
- activeForm: candidate.activeForm,
- };
- });
-}
-
-function validateSingleInProgress(todos: TodoItem[]) {
- const inProgressCount = todos.filter((todo) => todo.status === "in_progress").length;
- if (inProgressCount > 1) {
- throw new Error(
- `Only one todo may be in_progress at a time; found ${inProgressCount}. Mark others as pending or completed.`,
- );
- }
-}
-
-function buildTodoWriteResultText(todos: TodoItem[]) {
- if (todos.length === 0) {
- return "Task list cleared.";
- }
- const completed = todos.filter((todo) => todo.status === "completed").length;
- return [
- `Task list updated (${completed}/${todos.length} completed).`,
- ...todos.map((todo, index) => `${index + 1}. [${todo.status}] ${todo.content}`),
- ].join("\n");
-}
-
-export function createTodoTools(params: { state: TodoToolState }): BuiltinToolBundle {
- const toolTodoWrite: Tool = {
- name: "TodoWrite",
- description: TODO_WRITE_TOOL_DESCRIPTION,
- parameters: todoWriteParameters,
- };
-
- async function executeToolCall(
- toolCall: ToolCall,
- signal?: AbortSignal,
- ): Promise {
- const now = Date.now();
- if (signal?.aborted) {
- return {
- role: "toolResult",
- toolCallId: toolCall.id,
- toolName: toolCall.name,
- content: [{ type: "text", text: "Cancelled" }],
- details: {},
- isError: true,
- timestamp: now,
- };
- }
- if (toolCall.name !== "TodoWrite") {
- return {
- role: "toolResult",
- toolCallId: toolCall.id,
- toolName: toolCall.name,
- content: [{ type: "text", text: `Unknown tool: ${toolCall.name}` }],
- details: {},
- isError: true,
- timestamp: now,
- };
- }
-
- try {
- const args = (toolCall.arguments || {}) as Record;
- const todos = validateTodoShape(args);
- validateSingleInProgress(todos);
- params.state.setTodos(todos);
- return {
- role: "toolResult",
- toolCallId: toolCall.id,
- toolName: toolCall.name,
- content: [{ type: "text", text: buildTodoWriteResultText(todos) }],
- details: { kind: "todo_write", todos },
- isError: false,
- timestamp: now,
- };
- } catch (error) {
- return {
- role: "toolResult",
- toolCallId: toolCall.id,
- toolName: toolCall.name,
- content: [
- { type: "text", text: error instanceof Error ? error.message : "TodoWrite failed." },
- ],
- details: {},
- isError: true,
- timestamp: now,
- };
- }
- }
-
- return {
- groupId: "system",
- tools: [toolTodoWrite],
- executeToolCall,
- metadataByName: createBuiltinMetadataMap([
- [
- "TodoWrite",
- {
- groupId: "system",
- kind: "todo_write",
- isReadOnly: false,
- displayCategory: "system",
- },
- ],
- ]),
- };
-}
diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx
index 327fca55d..41ff27cb4 100644
--- a/crates/agent-gui/src/pages/ChatPage.tsx
+++ b/crates/agent-gui/src/pages/ChatPage.tsx
@@ -10,7 +10,6 @@ import { NotifyToast } from "@liveagent/ui/components/chat/NotifyToast";
import { SharedHistoryManagerModal } from "@liveagent/ui/components/chat/SharedHistoryManagerModal";
import { TaskProgressIndicator } from "@liveagent/ui/components/chat/TaskProgressIndicator";
import { ToolApprovalBar } from "@liveagent/ui/components/chat/ToolApprovalBar";
-import { useSequencedTaskProgress } from "@liveagent/ui/components/chat/useSequencedTaskProgress";
import { WorkspaceCloneModal } from "@liveagent/ui/components/chat/WorkspaceCloneModal";
import { WorkspaceResourceSettingsDrawer } from "@liveagent/ui/components/chat/WorkspaceResourceSettingsDrawer";
import type {
@@ -25,7 +24,7 @@ import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog";
import { useLocale } from "@liveagent/ui/i18n/index";
import { getAutomationState, useAutomation } from "@liveagent/ui/lib/automation/index";
import { openChatFileLink } from "@liveagent/ui/lib/chat/openChatFileLink";
-import { selectTodoProgressUpdates } from "@liveagent/ui/lib/chat/taskProgress";
+import { selectLatestTaskProgress } from "@liveagent/ui/lib/chat/taskProgress";
import type { ScrollFollowHandle } from "@liveagent/ui/lib/chat-scroll/useScrollFollow";
import { setPreferredMonacoNlsLocale } from "@liveagent/ui/lib/monacoNls";
import {
@@ -107,7 +106,6 @@ import { createGuiSidebarBackend } from "../lib/sidebar/guiSidebarBackend";
import { createSubagentStoreManager } from "../lib/subagents";
import { tauriTerminalClient } from "../lib/terminal/tauriTerminalClient";
import { cancelPendingAskUserQuestionsForConversation } from "../lib/tools/askUserQuestionTools";
-import { disposeTodoToolState } from "../lib/tools/todoTools";
import {
answerToolApproval,
cancelPendingToolApprovalsForConversation,
@@ -191,11 +189,10 @@ function CurrentTaskProgress(props: {
getLiveRoundsSnapshot,
getLiveRoundsSnapshot,
);
- const updates = useMemo(
- () => selectTodoProgressUpdates(historyItems, liveRounds),
+ const snapshot = useMemo(
+ () => selectLatestTaskProgress(historyItems, liveRounds),
[historyItems, liveRounds],
);
- const snapshot = useSequencedTaskProgress(updates, isConversationRunning);
const labels = useMemo(() => {
if (!snapshot) return null;
return {
@@ -1068,7 +1065,6 @@ export function ChatPage(props: ChatPageProps) {
onPruneConversation: (conversationId) => {
deleteConversationLocalCaches(conversationId);
subagentStoresRef.current.dispose(conversationId);
- disposeTodoToolState(conversationId);
cancelPendingAskUserQuestionsForConversation(conversationId);
cancelPendingToolApprovalsForConversation(conversationId);
},
diff --git a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx
index 39e2c2c79..536641ee7 100644
--- a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx
+++ b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx
@@ -77,7 +77,6 @@ export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: {
runningToolCallIds={unit.runningToolCallIds}
thinkingOpen={unit.thinkingOpen}
isLatestThinking={unit.isLatestThinking}
- isAborted={row.isAborted}
workdir={workdir}
onOpenFileLink={onOpenFileLink}
/>
diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx
index b53c7c315..01adf9834 100644
--- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx
+++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx
@@ -67,7 +67,6 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: {
runningToolCallIds: string[];
thinkingOpen: boolean;
isLatestThinking: boolean;
- isAborted: boolean;
workdir?: string;
onOpenFileLink?: (link: ChatFileLink) => void;
}) {
@@ -78,7 +77,6 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: {
runningToolCallIds,
thinkingOpen,
isLatestThinking,
- isAborted,
workdir,
onOpenFileLink,
} = props;
@@ -106,7 +104,6 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: {
content = (
+
);
} else if (block.kind === "hostedSearch" || block.kind === "hostedSearchGroup") {
content = (
@@ -144,19 +137,5 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: {
if (!content) return null;
- return (
- span:last-child]:!text-muted-foreground/40 [&_.todo-list-view_[data-todo-incomplete]>span:last-child]:line-through"
- : ""
- }`
- }
- >
- {content}
-
- );
+ return {content}
;
});
diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx
index 4f87a3780..8d6e418ec 100644
--- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx
+++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx
@@ -4,7 +4,6 @@ import { AssistantStatus } from "@liveagent/ui/components/chat/AssistantStatus";
import { FileChangeBadge } from "@liveagent/ui/components/chat/FileChangeBadge";
import { FileToolArgsDisplay } from "@liveagent/ui/components/chat/FileToolArgs";
import { LazyCollapse } from "@liveagent/ui/components/chat/LazyCollapse";
-import { sanitizeTodoItems, TodoListView } from "@liveagent/ui/components/chat/TodoListView";
import { useLocale } from "@liveagent/ui/i18n/index";
import {
ASK_USER_QUESTION_TOOL_NAME,
@@ -183,12 +182,6 @@ function ToolArgsDisplay({ item }: { item: ToolTraceItem }) {
return ;
}
- // TodoWrite args ARE the checklist — render them with the same view as the
- // result instead of dumping raw JSON (shown only until the result lands).
- if (toolCall.name === "TodoWrite") {
- return ;
- }
-
const display = getToolDisplay(toolCall);
if (isSubagentCardToolCall(toolCall)) {
@@ -322,31 +315,10 @@ function getRawArgsDisplayText(toolCall: ToolTraceItem["toolCall"]) {
return text;
}
-function ToolCallItem({
- item,
- isRunning,
- isAborted = false,
-}: {
- item: ToolTraceItem;
- isRunning?: boolean;
- isAborted?: boolean;
-}) {
+function ToolCallItem({ item, isRunning }: { item: ToolTraceItem; isRunning?: boolean }) {
const { t } = useLocale();
const result = item.toolResult;
const builtinResultKind = getBuiltinResultKind(result);
- const isTodo = item.toolCall.name === "TodoWrite";
- const todoItems = isTodo
- ? sanitizeTodoItems(
- builtinResultKind === "todo_write"
- ? (result?.details as { todos?: unknown } | undefined)?.todos
- : item.toolCall.arguments?.todos,
- )
- : [];
- const hasIncompleteTodo = todoItems.some((todo) => todo.status !== "completed");
- const shouldKeepTodoOpen =
- isTodo && (Boolean(isRunning) || !result || Boolean(result.isError) || hasIncompleteTodo);
- const shouldCloseCompletedTodo =
- isTodo && Boolean(result && !result.isError) && todoItems.length > 0 && !hasIncompleteTodo;
const isAskUser = item.toolCall.name === ASK_USER_QUESTION_TOOL_NAME;
const askDetails = isAskUser ? parseAskUserQuestionResultDetails(result?.details) : null;
// 参数生成完毕(onToolCall 之后才会入回合)才渲染卡片;对历史/降级数据
@@ -358,7 +330,7 @@ function ToolCallItem({
? askDetails.questions
: sanitizeAskUserQuestionItems(item.toolCall.arguments?.questions)
: [];
- // 提问卡运行期强制展开等待作答;应答落定后自动收起(同 Todo 完成收起)。
+ // 提问卡运行期强制展开等待作答;应答落定后自动收起。
const shouldKeepAskOpen = isAskUser && (Boolean(isRunning) || !result);
const shouldCloseAnsweredAsk = isAskUser && Boolean(result);
// 权威应答截止时间来自工具挂起表;卡片倒计时与超时兜底同源,
@@ -377,19 +349,13 @@ function ToolCallItem({
useSyncExternalStore(subscribeToolApprovals, getToolApprovalVersion, getToolApprovalVersion);
const pendingApproval = getPendingToolApproval(item.toolCall.id);
const shouldAutoOpen =
- item.toolCall.name === "Image" ||
- builtinResultKind === "display_image" ||
- shouldKeepTodoOpen ||
- shouldKeepAskOpen;
+ item.toolCall.name === "Image" || builtinResultKind === "display_image" || shouldKeepAskOpen;
const [open, setOpen] = useState(shouldAutoOpen);
const isSubagentCard = isSubagentCardToolCall(item.toolCall);
const hasArgs = Object.keys(item.toolCall.arguments || {}).length > 0;
const isStreamingFilePreviewTool = FILE_TOOL_TEXT_FIELDS[item.toolCall.name] !== undefined;
const shouldShowArgs =
- !isAskUser &&
- (!isSubagentCard || !result) &&
- (item.toolCall.name !== "TodoWrite" || !result) &&
- (isStreamingFilePreviewTool ? !result : hasArgs);
+ !isAskUser && (!isSubagentCard || !result) && (isStreamingFilePreviewTool ? !result : hasArgs);
const isBash = item.toolCall.name === "Bash";
const isManagedProcess = item.toolCall.name === "ManagedProcess";
const inlineCommand =
@@ -411,49 +377,37 @@ function ToolCallItem({
const fileChangeStats = useMemo(() => deriveFileChangeStats(item.toolCall), [item.toolCall]);
const meta = getToolMeta(item.toolCall.name);
const ToolIcon = meta.Icon;
- const title =
- item.toolCall.name === "TodoWrite"
- ? { name: t("chat.tool.todoTitle"), action: "" }
- : isAskUser
- ? { name: t("chat.tool.askUserTitle"), action: "" }
- : getToolDisplayTitle(item.toolCall);
-
- const statusLabel =
- isTodo && hasIncompleteTodo && isAborted
- ? t("chat.tool.aborted")
- : pendingApproval
- ? t("chat.toolApproval.waitingStatus")
- : isRunning
- ? isAskUser
- ? askQuestions.length > 0
- ? t("chat.askUser.waiting")
- : t("chat.askUser.preparing")
- : t("chat.tool.running")
- : result
- ? result.isError
- ? t("chat.tool.failed")
- : t("chat.tool.success")
- : t("chat.tool.waiting");
+ const title = isAskUser
+ ? { name: t("chat.tool.askUserTitle"), action: "" }
+ : getToolDisplayTitle(item.toolCall);
+
+ const statusLabel = pendingApproval
+ ? t("chat.toolApproval.waitingStatus")
+ : isRunning
+ ? isAskUser
+ ? askQuestions.length > 0
+ ? t("chat.askUser.waiting")
+ : t("chat.askUser.preparing")
+ : t("chat.tool.running")
+ : result
+ ? result.isError
+ ? t("chat.tool.failed")
+ : t("chat.tool.success")
+ : t("chat.tool.waiting");
const statusTextClass = result?.isError
? "text-[hsl(var(--chat-error))]"
: "text-muted-foreground/60";
useEffect(() => {
- if (shouldKeepTodoOpen || shouldKeepAskOpen) {
+ if (shouldKeepAskOpen) {
setOpen(true);
- } else if (shouldCloseCompletedTodo || shouldCloseAnsweredAsk) {
+ } else if (shouldCloseAnsweredAsk) {
setOpen(false);
} else if (shouldAutoOpen) {
setOpen(true);
}
- }, [
- shouldAutoOpen,
- shouldCloseAnsweredAsk,
- shouldCloseCompletedTodo,
- shouldKeepAskOpen,
- shouldKeepTodoOpen,
- ]);
+ }, [shouldAutoOpen, shouldCloseAnsweredAsk, shouldKeepAskOpen]);
const canExpand = shouldShowArgs || Boolean(result) || (isAskUser && askQuestions.length > 0);
@@ -559,7 +513,7 @@ function ToolCallItem({
{/* 提问卡自带应答态展示;仅参数校验失败(无 details)时回落默认错误区。 */}
{result && (!isAskUser || !askDetails) ? (
@@ -653,6 +607,5 @@ export const MemoToolCallItem = memo(
ToolCallItem,
(previousProps, nextProps) =>
previousProps.isRunning === nextProps.isRunning &&
- previousProps.isAborted === nextProps.isAborted &&
areToolTraceItemsEqual(previousProps.item, nextProps.item),
);
diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolResultDisplay.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolResultDisplay.tsx
index 879c00b42..c1e4e1449 100644
--- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolResultDisplay.tsx
+++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolResultDisplay.tsx
@@ -1,6 +1,5 @@
import type { ToolResultMessage } from "@earendil-works/pi-ai";
import { EditDiffView } from "@liveagent/ui/components/chat/EditDiffView";
-import { TodoListView } from "@liveagent/ui/components/chat/TodoListView";
import { Markdown } from "@liveagent/ui/components/Markdown";
import { cn } from "@liveagent/ui/lib/shared/utils";
import type {
@@ -27,7 +26,6 @@ import type {
ReadPdfResultDetails,
ReadTextResultDetails,
SkillsManagerResultDetails,
- TodoWriteResultDetails,
WriteResultDetails,
} from "../../../../lib/tools/builtinTypes";
import {
@@ -251,11 +249,6 @@ export function ToolResultDisplay({
);
}
- if (kind === "todo_write") {
- const details = result.details as TodoWriteResultDetails;
- return ;
- }
-
if (kind === "read_text") {
const details = result.details as ReadTextResultDetails;
return (
diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolTraceGroup.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolTraceGroup.tsx
index 8570b73cb..a20b80f42 100644
--- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolTraceGroup.tsx
+++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolTraceGroup.tsx
@@ -14,12 +14,8 @@ import {
} from "./assistantBubbleUtils";
import { areToolTraceItemsEqual, MemoToolCallItem } from "./ToolCallItem";
-function ToolTraceGroupInner(props: {
- items: ToolTraceItem[];
- runningToolCallIds?: string[];
- isAborted?: boolean;
-}) {
- const { items, runningToolCallIds = [], isAborted = false } = props;
+function ToolTraceGroupInner(props: { items: ToolTraceItem[]; runningToolCallIds?: string[] }) {
+ const { items, runningToolCallIds = [] } = props;
const { t } = useLocale();
const counts = useMemo(
() => getToolGroupCounts(items, runningToolCallIds),
@@ -40,7 +36,6 @@ function ToolTraceGroupInner(props: {
return item ? (
) : null;
@@ -109,7 +104,6 @@ function ToolTraceGroupInner(props: {
item === next.items[index] || areToolTraceItemsEqual(item, next.items[index]),
) &&
- previous.isAborted === next.isAborted &&
areRunningIdsEqual(previous.runningToolCallIds, next.runningToolCallIds),
);
diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/assistantBubbleUtils.ts b/crates/agent-gui/src/pages/chat/components/assistant-bubble/assistantBubbleUtils.ts
index 3eba04291..6d482f16c 100644
--- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/assistantBubbleUtils.ts
+++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/assistantBubbleUtils.ts
@@ -73,7 +73,9 @@ export function getToolMeta(name: string): {
return { Icon: Search, accent: "var(--tool-search-accent)", category: "search" };
case "List":
return { Icon: FolderTree, accent: "var(--tool-list-accent)", category: "list" };
- case "TodoWrite":
+ case "TaskCreate":
+ case "TaskUpdate":
+ case "TaskList":
return { Icon: ListChecks, accent: "var(--tool-list-accent)", category: "system" };
case "AskUserQuestion":
return { Icon: CircleHelp, accent: "var(--tool-list-accent)", category: "system" };
@@ -306,7 +308,9 @@ export function groupRoundBlocks(blocks: UiRound["blocks"]): GroupedRoundBlock[]
flushPendingSearches();
if (
block.item.toolCall.name === "Image" ||
- block.item.toolCall.name === "TodoWrite" ||
+ block.item.toolCall.name === "TaskCreate" ||
+ block.item.toolCall.name === "TaskUpdate" ||
+ block.item.toolCall.name === "TaskList" ||
block.item.toolCall.name === "AskUserQuestion" ||
isAgentToolName(block.item.toolCall.name)
) {
diff --git a/crates/agent-gui/src/pages/chat/history/useConversationHistoryActions.ts b/crates/agent-gui/src/pages/chat/history/useConversationHistoryActions.ts
index c83c36ca2..48d7a07a5 100644
--- a/crates/agent-gui/src/pages/chat/history/useConversationHistoryActions.ts
+++ b/crates/agent-gui/src/pages/chat/history/useConversationHistoryActions.ts
@@ -24,7 +24,6 @@ import {
waitForTitleLookahead,
} from "../../../lib/chat/page/chatPageHelpers";
import { type SelectedModel, serializeSelectedModelJson } from "../../../lib/settings";
-import { disposeTodoToolState } from "../../../lib/tools/todoTools";
import {
type ConversationRuntimeEntry,
createConversationRuntimeEntry,
@@ -139,7 +138,6 @@ export function useConversationHistoryActions(params: UseConversationHistoryActi
onPruneConversation: (conversationId) => {
deleteConversationArtifacts(conversationId);
disposeSubagentsForConversation?.(conversationId);
- disposeTodoToolState(conversationId);
},
});
}
diff --git a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts
index 8a1dc3fdc..39b761a48 100644
--- a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts
+++ b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts
@@ -24,8 +24,10 @@ import {
appendMessagesToConversation,
buildRequestContext,
type ConversationViewState,
+ clearTaskListState,
findHistoryMessageRefByMessageId,
type HistoryMessageRef,
+ setTaskListState,
} from "../../../lib/chat/conversation/conversationState";
import {
createConversationHookLifecycle,
@@ -71,6 +73,7 @@ import {
type SubagentStoreManager,
} from "../../../lib/subagents";
import type { SkillAccessPolicy } from "../../../lib/tools/skillAccessPolicy";
+import type { TaskStateStore } from "../../../lib/tools/taskTools";
import { appendManagedSkillSelections, asErrorMessage } from "../chatPageUtils";
import {
buildTextFromComposerDraft,
@@ -611,7 +614,7 @@ export function useSendChatTurn(params: UseSendChatTurnParams) {
providerId,
model,
});
- const baseConversationState = runtimeEntry.state;
+ const baseConversationState = clearTaskListState(runtimeEntry.state);
const isFirstTurn = baseConversationState.meta.totalMessageCount === 0;
const existingHistoryItem =
sidebarStore.peek(conversationId) ??
@@ -923,10 +926,14 @@ export function useSendChatTurn(params: UseSendChatTurnParams) {
if (overrides?.editResendBaseMessageRef) {
try {
- nextConversationState = await replaceConversationAtMessage(
- conversationId,
- overrides.editResendBaseMessageRef,
- pendingUserMessage,
+ // 重发同样是新用户消息开启新 Run:替换回来的历史 meta 可能带着上一
+ // Run 持久化的 taskList,必须与常规发送一样在 Run 边界清除。
+ nextConversationState = clearTaskListState(
+ await replaceConversationAtMessage(
+ conversationId,
+ overrides.editResendBaseMessageRef,
+ pendingUserMessage,
+ ),
);
initialUserTurnPersisted = true;
const keepParentToolCallIds =
@@ -1432,6 +1439,33 @@ export function useSendChatTurn(params: UseSendChatTurnParams) {
resetLiveTranscript(transcriptStore);
}
+ // Run 级任务清单存储:先落盘、成功后才应用到运行时状态,失败时状态从未
+ // 变更(无需回滚)。持久化走非终态通道——中途任务写盘失败只属于本次工具
+ // 调用(模型收到错误可重试),绝不能点亮 terminalHistoryPersistFailed 把
+ // 已成功收尾的 run 误报为 history_persist_failed。
+ const taskStateStore: TaskStateStore = {
+ runId: gatewayBridgeRequestId,
+ getState: () => nextConversationState.meta.taskList,
+ commitState: async (taskList) => {
+ const persisted = await persistConversationWithHistorySync({
+ conversationId,
+ sessionId,
+ providerId,
+ model,
+ selectedModel,
+ cwd: conversationCwd,
+ state: setTaskListState(nextConversationState, taskList),
+ fallbackTitle,
+ createdAt,
+ titlePromise,
+ }).catch(() => false);
+ if (!persisted) {
+ throw new Error("Failed to persist task state.");
+ }
+ applyConversationState(setTaskListState(nextConversationState, taskList));
+ },
+ };
+
try {
if (effectiveIsAgentMode) {
await chatRuntimeHost.runTurn({
@@ -1493,6 +1527,7 @@ export function useSendChatTurn(params: UseSendChatTurnParams) {
}
},
sessionId,
+ taskStateStore,
conversationId,
conversationCwd,
fallbackTitle,
diff --git a/crates/agent-gui/src/pages/chat/transcript/rowModel.ts b/crates/agent-gui/src/pages/chat/transcript/rowModel.ts
index 935e81ee0..86d83ffd0 100644
--- a/crates/agent-gui/src/pages/chat/transcript/rowModel.ts
+++ b/crates/agent-gui/src/pages/chat/transcript/rowModel.ts
@@ -1,4 +1,4 @@
-import { isTodoWriteToolBlock } from "@liveagent/ui/lib/chat/taskProgress";
+import { isTaskToolBlock } from "@liveagent/ui/lib/chat/taskProgress";
import {
CHECKPOINT_ROW_ESTIMATE_PX,
estimateAssistantRowHeight,
@@ -88,7 +88,6 @@ export type AssistantUnitRow = {
renderMode: "streaming" | "static";
compacted: boolean;
showAvatar: boolean;
- isAborted: boolean;
unit: AssistantRenderUnit;
};
@@ -128,7 +127,7 @@ function isVisibleGroupedBlock(block: GroupedRoundBlock) {
if (block.kind === "text" || block.kind === "thinking") {
return block.text.trim().length > 0;
}
- return !isTodoWriteToolBlock(block);
+ return !isTaskToolBlock(block);
}
function hasRunningToolCall(blocks: GroupedRoundBlock[], runningToolCallIds: string[]) {
@@ -248,7 +247,6 @@ function canReuseLiveUnit(previous: AssistantUnitRow, next: AssistantUnitRow) {
previous.renderMode !== next.renderMode ||
previous.compacted !== next.compacted ||
previous.showAvatar !== next.showAvatar ||
- previous.isAborted !== next.isAborted ||
previous.unit.kind !== "block" ||
next.unit.kind !== "block"
) {
@@ -318,7 +316,6 @@ function buildAssistantUnits(input: BuildAssistantUnitsInput): AssistantUnitRow[
anchorUserKey,
liveUnitCache,
} = input;
- const isAborted = rounds.some((round) => round.meta?.stopReason === "aborted");
const rows: AssistantUnitRow[] = [];
rounds.forEach((round) => {
@@ -350,7 +347,6 @@ function buildAssistantUnits(input: BuildAssistantUnitsInput): AssistantUnitRow[
renderMode,
compacted,
showAvatar: rows.length === 0,
- isAborted,
unit: {
kind: "block",
block,
@@ -388,7 +384,6 @@ function buildAssistantUnits(input: BuildAssistantUnitsInput): AssistantUnitRow[
renderMode,
compacted,
showAvatar: rows.length === 0,
- isAborted,
unit: { kind: "status" },
});
} else {
@@ -414,7 +409,6 @@ function buildAssistantUnits(input: BuildAssistantUnitsInput): AssistantUnitRow[
renderMode,
compacted,
showAvatar: rows.length === 0 && rounds.length > 0,
- isAborted,
unit: {
kind: "footer",
timestamp,
diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts
index eb7c635f0..19898f346 100644
--- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts
+++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts
@@ -75,7 +75,7 @@ import type { BuiltinToolExecutionContext } from "../../../lib/tools/builtinType
import { createFileToolState } from "../../../lib/tools/fileToolState";
import type { SkillAccessPolicy } from "../../../lib/tools/skillAccessPolicy";
import type { SshManagerSessionChange } from "../../../lib/tools/sshManagerTools";
-import { getOrCreateTodoToolState } from "../../../lib/tools/todoTools";
+import { formatTaskListRuntimeContext, type TaskStateStore } from "../../../lib/tools/taskTools";
import { isSessionApproved, requestToolApproval } from "../../../lib/tools/toolApproval";
import { resolveToolPolicy } from "../../../lib/tools/toolPolicy";
import type { TunnelManagerChange } from "../../../lib/tools/tunnelManagerTools";
@@ -238,6 +238,8 @@ export type RunAgentConversationTurnParams = {
sshManagerRemoteAllowed?: boolean;
onSshSessionsChanged?: (change: SshManagerSessionChange) => void;
sessionId: string;
+ /** Run 级任务状态存储:由 send 管线构建,提交走非终态持久化。 */
+ taskStateStore: TaskStateStore;
conversationId: string;
conversationCwd?: string;
fallbackTitle: string;
@@ -302,6 +304,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
sshManagerRemoteAllowed,
onSshSessionsChanged,
sessionId,
+ taskStateStore,
conversationId,
conversationCwd,
fallbackTitle,
@@ -380,7 +383,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
parentMessageBusSnapshot = await loadParentBusSnapshot();
return parentMessageBusSnapshot;
};
- const withSubagentRuntimeContext = (context: Context): Context => {
+ const withAgentRuntimeContext = (context: Context): Context => {
let systemPrompt = context.systemPrompt;
if (subagentReminder) {
systemPrompt = appendSystemPrompt(systemPrompt, subagentReminder);
@@ -388,6 +391,15 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
if (parentMessageBusSnapshot) {
systemPrompt = appendSystemPrompt(systemPrompt, parentMessageBusSnapshot);
}
+ // 只注入本 Run 的权威任务状态:edit-resend 等路径可能把上一 Run 持久化的
+ // taskList 带回 meta,工具层按 runId 视其为不存在,注入必须同口径。
+ const taskList = getNextConversationState().meta.taskList;
+ if (taskList && taskList.runId === taskStateStore.runId) {
+ const taskListContext = formatTaskListRuntimeContext(taskList);
+ if (taskListContext) {
+ systemPrompt = appendSystemPrompt(systemPrompt, taskListContext);
+ }
+ }
return systemPrompt !== context.systemPrompt
? {
...context,
@@ -396,7 +408,6 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
: context;
};
const fileState = createFileToolState();
- const todoState = getOrCreateTodoToolState(conversationId);
const subagentScheduler = createSubagentScheduler();
const runtimePlatform = await resolveRuntimePlatform();
const buildRegistryStartedAt = perfNowMs();
@@ -405,7 +416,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
providerId,
runtimePlatform,
fileState,
- todoState,
+ taskStateStore,
askUserQuestionConversationId: conversationId,
skillsEnabled: effectiveSkillsEnabled,
skillsRootDir,
@@ -458,7 +469,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
const preCompactionStartedAt = perfNowMs();
await compaction.maybeCompactPreSend({
- budgetContext: withSubagentRuntimeContext(
+ budgetContext: withAgentRuntimeContext(
buildPreparedContext(getNextConversationState(), combinedTools, {
includeUploadedFilesMetadata: true,
}),
@@ -726,7 +737,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
let midStreamCompactionRequested = false;
let sawToolCallInRound = false;
const nativeWebSearchEnabled = runtime.nativeWebSearchEnabled !== false;
- const agentContext = withSubagentRuntimeContext(
+ const agentContext = withAgentRuntimeContext(
pendingAgentContext ??
buildPreparedContext(getNextConversationState(), combinedTools, {
includeUploadedFilesMetadata: true,
@@ -939,7 +950,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
getNextConversationState(),
emittedMessages,
);
- const tempContext = withSubagentRuntimeContext(
+ const tempContext = withAgentRuntimeContext(
buildPreparedContext(tempState, combinedTools, {
includeUploadedFilesMetadata: true,
}),
@@ -962,7 +973,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
latestAgentEmittedMessages = [];
clearPersistableAgentProgress();
return {
- context: withSubagentRuntimeContext(compactedContext),
+ context: withAgentRuntimeContext(compactedContext),
emittedMessages: [],
};
},
@@ -1005,7 +1016,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
const compactionResult = await compaction.compactDuringRun({
trigger: "mid-stream",
state: tempState,
- budgetContext: withSubagentRuntimeContext(
+ budgetContext: withAgentRuntimeContext(
buildPreparedContext(tempState, combinedTools, {
includeAbortedMessages: true,
includeUploadedFilesMetadata: true,
diff --git a/crates/agent-gui/test/chat/agent-turn-cancelled-history.test.mjs b/crates/agent-gui/test/chat/agent-turn-cancelled-history.test.mjs
index 2d30f4712..75a099baa 100644
--- a/crates/agent-gui/test/chat/agent-turn-cancelled-history.test.mjs
+++ b/crates/agent-gui/test/chat/agent-turn-cancelled-history.test.mjs
@@ -75,8 +75,8 @@ const memoryExtractionPath = fileURLToPath(
const fileToolStatePath = fileURLToPath(
new URL("../../src/lib/tools/fileToolState.ts", import.meta.url),
);
-const todoToolsPath = fileURLToPath(
- new URL("../../src/lib/tools/todoTools.ts", import.meta.url),
+const taskToolsPath = fileURLToPath(
+ new URL("../../src/lib/tools/taskTools.ts", import.meta.url),
);
async function replayCancelledHistoryScenario(params) {
@@ -142,9 +142,9 @@ const loader = createTsModuleLoader({
return {};
},
},
- [todoToolsPath]: {
- getOrCreateTodoToolState() {
- return {};
+ [taskToolsPath]: {
+ formatTaskListRuntimeContext() {
+ return "";
},
},
},
diff --git a/crates/agent-gui/test/chat/block-round-keys.test.mjs b/crates/agent-gui/test/chat/block-round-keys.test.mjs
index 3ec6eb693..63cb16178 100644
--- a/crates/agent-gui/test/chat/block-round-keys.test.mjs
+++ b/crates/agent-gui/test/chat/block-round-keys.test.mjs
@@ -113,7 +113,7 @@ test("ordinary tool activity keeps one group identity as later tools append", ()
});
test("special tool result updates preserve their direct activity identity", () => {
- for (const name of ["TodoWrite", "AskUserQuestion", "Image", "Agent"]) {
+ for (const name of ["TaskCreate", "TaskUpdate", "TaskList", "AskUserQuestion", "Image", "Agent"]) {
const pendingItem = {
toolCall: { type: "toolCall", id: `call-${name}`, name, arguments: {} },
};
diff --git a/crates/agent-gui/test/chat/chat-history-persist-queue.test.mjs b/crates/agent-gui/test/chat/chat-history-persist-queue.test.mjs
index d14511b63..cb18497a8 100644
--- a/crates/agent-gui/test/chat/chat-history-persist-queue.test.mjs
+++ b/crates/agent-gui/test/chat/chat-history-persist-queue.test.mjs
@@ -168,6 +168,8 @@ test("queued persists read the latest persistence cursor inside the conversation
assert.equal(recorder.calls.length, 1);
assert.equal(recorder.calls[0].cmd, "chat_history_append_segment");
+ assert.equal(recorder.calls[0].args.input.previousSegment.segmentId, "seg-0");
+ assert.equal(recorder.calls[0].args.input.previousSegment.messageCount, 2);
assert.deepEqual(cursorReads, [persistenceCursor(seg0)]);
await resolveCall(recorder.calls[0], "conv-1", 10);
@@ -274,6 +276,8 @@ test("persistence cursor selects explicit initial active and append transitions"
);
await flush();
assert.equal(recorder.calls[2].cmd, "chat_history_append_segment");
+ assert.equal(recorder.calls[2].args.input.previousSegment.segmentId, "seg-0");
+ assert.equal(recorder.calls[2].args.input.previousSegment.messageCount, 2);
assert.equal(recorder.calls[2].args.input.segment.segmentId, "seg-1");
await resolveCall(recorder.calls[2], conversationId, 22);
await append;
@@ -384,3 +388,129 @@ test("edit-resend uses one atomic replace command that returns the refreshed tai
assert.equal(result.activeSegment.segmentId, "seg-0");
assert.equal(result.meta.totalMessageCount, 3);
});
+
+test("history window restores the exact persisted task list state", async () => {
+ const recorder = createInvokeRecorder();
+ const chatHistory = loadChatHistory(recorder.invoke);
+ const taskList = {
+ runId: "run-history",
+ revision: 4,
+ nextTaskId: 3,
+ tasks: [
+ {
+ id: "1",
+ subject: "Inspect",
+ description: "Inspect the history path",
+ activeForm: "Inspecting history",
+ status: "completed",
+ },
+ {
+ id: "2",
+ subject: "Restore",
+ description: "Restore the same task identities",
+ activeForm: "Restoring tasks",
+ status: "in_progress",
+ },
+ ],
+ };
+ const pending = chatHistory.getChatHistoryWindow({
+ id: "conv-task-state",
+ maxMessages: 360,
+ includeActiveSegment: true,
+ });
+ await flush();
+
+ recorder.calls[0].deferred.resolve({
+ conversation: summaryFor("conv-task-state", 600),
+ contextMetaJson: JSON.stringify({ systemPrompt: "prompt", taskList }),
+ activeSegmentIndex: 0,
+ totalSegmentCount: 1,
+ totalMessageCount: 0,
+ returnedMessageCount: 0,
+ oldestOffset: 0,
+ hasMoreBefore: false,
+ revision: "conv-task-state:600:0:1:0",
+ updatedAt: 600,
+ activeSegment: {
+ segmentIndex: 0,
+ segmentId: "seg-task",
+ messagesJson: "[]",
+ messageCount: 0,
+ createdAt: 600,
+ updatedAt: 600,
+ },
+ segments: [
+ {
+ segmentIndex: 0,
+ segmentId: "seg-task",
+ messagesJson: "[]",
+ startMessageIndex: 0,
+ messageCount: 0,
+ createdAt: 600,
+ updatedAt: 600,
+ },
+ ],
+ });
+
+ const window = await pending;
+ assert.deepEqual(window.meta.taskList, taskList);
+ assert.deepEqual(chatHistory.buildConversationStateFromWindow(window).meta.taskList, taskList);
+});
+
+test("a corrupt persisted task list is dropped instead of failing the window open", async () => {
+ const recorder = createInvokeRecorder();
+ const chatHistory = loadChatHistory(recorder.invoke);
+ const pending = chatHistory.getChatHistoryWindow({
+ id: "conv-task-corrupt",
+ maxMessages: 360,
+ includeActiveSegment: true,
+ });
+ await flush();
+
+ recorder.calls[0].deferred.resolve({
+ conversation: summaryFor("conv-task-corrupt", 700),
+ // duplicate task ids violate the strict task-state parser
+ contextMetaJson: JSON.stringify({
+ systemPrompt: "prompt",
+ taskList: {
+ runId: "run-corrupt",
+ revision: 1,
+ nextTaskId: 2,
+ tasks: [
+ { id: "1", subject: "A", description: "A", activeForm: "A", status: "pending" },
+ { id: "1", subject: "B", description: "B", activeForm: "B", status: "pending" },
+ ],
+ },
+ }),
+ activeSegmentIndex: 0,
+ totalSegmentCount: 1,
+ totalMessageCount: 0,
+ returnedMessageCount: 0,
+ oldestOffset: 0,
+ hasMoreBefore: false,
+ revision: "conv-task-corrupt:700:0:1:0",
+ updatedAt: 700,
+ activeSegment: {
+ segmentIndex: 0,
+ segmentId: "seg-corrupt",
+ messagesJson: "[]",
+ messageCount: 0,
+ createdAt: 700,
+ updatedAt: 700,
+ },
+ segments: [
+ {
+ segmentIndex: 0,
+ segmentId: "seg-corrupt",
+ messagesJson: "[]",
+ startMessageIndex: 0,
+ messageCount: 0,
+ createdAt: 700,
+ updatedAt: 700,
+ },
+ ],
+ });
+
+ const window = await pending;
+ assert.equal(window.meta.taskList, undefined);
+});
diff --git a/crates/agent-gui/test/chat/compaction-controller.test.mjs b/crates/agent-gui/test/chat/compaction-controller.test.mjs
index f4762e2e3..1deecb960 100644
--- a/crates/agent-gui/test/chat/compaction-controller.test.mjs
+++ b/crates/agent-gui/test/chat/compaction-controller.test.mjs
@@ -442,6 +442,85 @@ test("escalation ladder: consecutive ineffective compactions advise but never ha
assert.match(runningTexts[2], /建议适时开启新会话/);
});
+test("two consecutive compaction checkpoints preserve the exact authoritative task state", async () => {
+ const controller = new CompactionController();
+ const { recorder } = bindController(controller, {
+ complete: async () => summaryResponse(),
+ });
+ const taskList = {
+ runId: "run-through-two-compactions",
+ revision: 5,
+ nextTaskId: 3,
+ tasks: [
+ {
+ id: "1",
+ subject: "Inspect compaction",
+ description: "Verify task state survives every checkpoint",
+ activeForm: "Inspecting compaction",
+ status: "completed",
+ },
+ {
+ id: "2",
+ subject: "Finish implementation",
+ description: "Keep working on the same stable task",
+ activeForm: "Finishing implementation",
+ status: "in_progress",
+ },
+ ],
+ };
+ const initialState = conversationState.setTaskListState(bigState(), taskList);
+
+ const first = await controller.compactDuringRun({
+ trigger: "post-tool",
+ state: initialState,
+ });
+ assert.ok(first.context);
+ const firstCheckpointState = recorder.byKind("applyStateMidRun").at(-1)[1];
+ assert.deepEqual(firstCheckpointState.meta.taskList, taskList);
+
+ const secondInput = conversationState.appendMessagesToConversation(firstCheckpointState, [
+ user("continue task 2", 20),
+ user("keep the same task ids", 21),
+ user("verify state again", 22),
+ assistantWithUsage("continuing the same task", 190_000, 23),
+ ]);
+ const second = await controller.compactDuringRun({
+ trigger: "post-tool",
+ state: secondInput,
+ });
+ assert.ok(second.context);
+ const secondCheckpointState = recorder.byKind("applyStateMidRun").at(-1)[1];
+
+ assert.deepEqual(secondCheckpointState.meta.taskList, taskList);
+ assert.deepEqual(secondCheckpointState.meta.taskList, firstCheckpointState.meta.taskList);
+});
+
+test("a rejected checkpoint persist never switches runtime state to the unpersisted segment", async () => {
+ const controller = new CompactionController();
+ const { recorder } = bindController(controller, {
+ complete: async () => summaryResponse(),
+ });
+ recorder.sinks.persist = async (state) => {
+ recorder.events.push(["persist", state]);
+ return false;
+ };
+
+ const result = await controller.compactDuringRun({
+ trigger: "post-tool",
+ state: bigState(),
+ });
+
+ assert.equal(result.context, null);
+ assert.equal(result.shouldDisableProtection, false);
+ assert.equal(recorder.byKind("persist").length, 1);
+ assert.equal(recorder.byKind("queueCheckpoint").length, 0);
+ assert.ok(
+ recorder
+ .byKind("applyStateMidRun")
+ .every(([, state]) => state.meta.activeSegmentIndex === 0),
+ );
+});
+
test("registry hands out one controller per conversation and disposes cleanly", () => {
const registry = createCompactionControllerRegistry();
const a = registry.get("conv-a");
diff --git a/crates/agent-gui/test/chat/edit-resend-atomic.test.mjs b/crates/agent-gui/test/chat/edit-resend-atomic.test.mjs
index 60a3553ee..8c0f81064 100644
--- a/crates/agent-gui/test/chat/edit-resend-atomic.test.mjs
+++ b/crates/agent-gui/test/chat/edit-resend-atomic.test.mjs
@@ -92,8 +92,9 @@ test("edit-resend reports a rejected send without mutating history itself", asyn
});
test("send preflight atomically persists the replacement before starting the runtime", () => {
+ // 替换结果在 Run 边界清除上一 Run 的 taskList 后落入 nextConversationState。
const replaceIndex = sendSource.indexOf(
- "nextConversationState = await replaceConversationAtMessage(",
+ "nextConversationState = clearTaskListState(\n await replaceConversationAtMessage(",
);
const runtimeStartIndex = sendSource.indexOf(
"setConversationStopHandler(conversationId, handleConversationStop);",
diff --git a/crates/agent-gui/test/chat/task-progress-indicator.test.mjs b/crates/agent-gui/test/chat/task-progress-indicator.test.mjs
index 7b16c8467..a2d4e7fec 100644
--- a/crates/agent-gui/test/chat/task-progress-indicator.test.mjs
+++ b/crates/agent-gui/test/chat/task-progress-indicator.test.mjs
@@ -94,17 +94,37 @@ function createIndicatorHarness() {
}
function createSnapshot(overrides = {}) {
- const todos =
- overrides.todos ??
+ const tasks =
+ overrides.tasks ??
[
- { content: "Inspect", status: "completed", activeForm: "Inspecting" },
- { content: "Implement", status: "in_progress", activeForm: "Implementing" },
- { content: "Verify", status: "pending", activeForm: "Verifying" },
+ {
+ id: "1",
+ subject: "Inspect",
+ description: "Inspect completion criteria",
+ status: "completed",
+ activeForm: "Inspecting",
+ },
+ {
+ id: "2",
+ subject: "Implement",
+ description: "Implement completion criteria",
+ status: "in_progress",
+ activeForm: "Implementing",
+ },
+ {
+ id: "3",
+ subject: "Verify",
+ description: "Verify completion criteria",
+ status: "pending",
+ activeForm: "Verifying",
+ },
];
return {
- todos,
+ runId: "run-1",
+ revision: 3,
+ tasks,
completedCount: 1,
- totalCount: todos.length,
+ totalCount: tasks.length,
currentStep: 2,
state: "in_progress",
...overrides,
@@ -197,7 +217,15 @@ test("renders props-only copy, progress semantics, and an absolute reduced-motio
test("keeps task labels stable and scopes transition motion to the changed row status", () => {
const indicator = createIndicatorHarness();
const runningSnapshot = createSnapshot({
- todos: [{ content: "Stable task", status: "in_progress", activeForm: "Changing label" }],
+ tasks: [
+ {
+ id: "stable",
+ subject: "Stable task",
+ description: "Stable completion criteria",
+ status: "in_progress",
+ activeForm: "Changing label",
+ },
+ ],
completedCount: 0,
totalCount: 1,
currentStep: 1,
@@ -218,7 +246,15 @@ test("keeps task labels stable and scopes transition motion to the changed row s
const completedTree = indicator.render({
snapshot: createSnapshot({
- todos: [{ content: "Stable task", status: "completed", activeForm: "Changed again" }],
+ tasks: [
+ {
+ id: "stable",
+ subject: "Stable task",
+ description: "Stable completion criteria",
+ status: "completed",
+ activeForm: "Changed again",
+ },
+ ],
completedCount: 1,
totalCount: 1,
currentStep: 1,
@@ -301,7 +337,15 @@ test("Escape closes while touch clicks toggle", () => {
test("shows pending, paused, and completed states without auto-dismissing completion", () => {
const indicator = createIndicatorHarness();
const pending = createSnapshot({
- todos: [{ content: "Wait", status: "pending", activeForm: "Waiting" }],
+ tasks: [
+ {
+ id: "wait",
+ subject: "Wait",
+ description: "Wait completion criteria",
+ status: "pending",
+ activeForm: "Waiting",
+ },
+ ],
completedCount: 0,
totalCount: 1,
currentStep: 1,
@@ -313,11 +357,17 @@ test("shows pending, paused, and completed states without auto-dismissing comple
/Paused/,
);
- const completedTodos = [
- { content: "Done", status: "completed", activeForm: "Finishing" },
+ const completedTasks = [
+ {
+ id: "done",
+ subject: "Done",
+ description: "Done completion criteria",
+ status: "completed",
+ activeForm: "Finishing",
+ },
];
const completed = createSnapshot({
- todos: completedTodos,
+ tasks: completedTasks,
completedCount: 1,
totalCount: 1,
currentStep: 1,
diff --git a/crates/agent-gui/test/chat/task-progress-sequence.test.mjs b/crates/agent-gui/test/chat/task-progress-sequence.test.mjs
deleted file mode 100644
index 9bc9cac9b..000000000
--- a/crates/agent-gui/test/chat/task-progress-sequence.test.mjs
+++ /dev/null
@@ -1,373 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-
-import { createTsModuleLoader } from "../helpers/load-ts-module.mjs";
-
-function createHookHarness() {
- const states = [];
- const refs = [];
- const effects = [];
- let stateIndex = 0;
- let refIndex = 0;
- let effectIndex = 0;
- let pendingEffects = [];
-
- const react = {
- useState(initialValue) {
- const index = stateIndex++;
- if (!(index in states)) {
- states[index] = typeof initialValue === "function" ? initialValue() : initialValue;
- }
- return [
- states[index],
- (next) => {
- states[index] = typeof next === "function" ? next(states[index]) : next;
- },
- ];
- },
- useRef(initialValue) {
- const index = refIndex++;
- if (!(index in refs)) refs[index] = { current: initialValue };
- return refs[index];
- },
- useEffect(effect, dependencies) {
- const index = effectIndex++;
- const previous = effects[index];
- const changed =
- !previous ||
- dependencies.length !== previous.dependencies.length ||
- dependencies.some((dependency, dependencyIndex) => !Object.is(dependency, previous.dependencies[dependencyIndex]));
- if (changed) pendingEffects.push({ index, effect, dependencies });
- },
- };
-
- return {
- react,
- render(run) {
- stateIndex = 0;
- refIndex = 0;
- effectIndex = 0;
- pendingEffects = [];
- const value = run();
- const scheduled = pendingEffects;
- pendingEffects = [];
- for (const entry of scheduled) {
- effects[entry.index]?.cleanup?.();
- effects[entry.index] = {
- dependencies: entry.dependencies,
- cleanup: entry.effect() ?? undefined,
- };
- }
- return value;
- },
- unmount() {
- for (const effect of effects) effect?.cleanup?.();
- },
- };
-}
-
-function installFakeWindow() {
- const previousWindow = globalThis.window;
- const timers = new Map();
- const delays = [];
- let nextId = 1;
- globalThis.window = {
- setTimeout(callback, delay) {
- const id = nextId++;
- timers.set(id, callback);
- delays.push(delay);
- return id;
- },
- clearTimeout(id) {
- timers.delete(id);
- },
- };
- return {
- delays,
- get size() {
- return timers.size;
- },
- runNext() {
- const next = timers.entries().next().value;
- assert.ok(next, "expected a queued sequence timer");
- const [id, callback] = next;
- timers.delete(id);
- callback();
- },
- restore() {
- if (previousWindow === undefined) delete globalThis.window;
- else globalThis.window = previousWindow;
- },
- };
-}
-
-function snapshot(completedCount) {
- const todos = [
- { content: "One", activeForm: "Working one", status: completedCount >= 1 ? "completed" : "in_progress" },
- {
- content: "Two",
- activeForm: "Working two",
- status: completedCount >= 2 ? "completed" : completedCount === 1 ? "in_progress" : "pending",
- },
- { content: "Three", activeForm: "Working three", status: completedCount >= 2 ? "in_progress" : "pending" },
- ];
- return {
- todos,
- completedCount,
- totalCount: todos.length,
- currentStep: Math.min(completedCount + 1, todos.length),
- state: "in_progress",
- };
-}
-
-function snapshotFromTodos(todos) {
- const completedCount = todos.filter((todo) => todo.status === "completed").length;
- const inProgressIndex = todos.findIndex((todo) => todo.status === "in_progress");
- const pendingIndex = todos.findIndex((todo) => todo.status === "pending");
- return {
- todos,
- completedCount,
- totalCount: todos.length,
- currentStep:
- inProgressIndex >= 0 ? inProgressIndex + 1 : pendingIndex >= 0 ? pendingIndex + 1 : todos.length,
- state:
- completedCount === todos.length
- ? "completed"
- : inProgressIndex >= 0
- ? "in_progress"
- : "pending",
- };
-}
-
-const update = (key, completedCount) => ({ key, snapshot: snapshot(completedCount) });
-
-test("GUI sequencer presents batched real updates one at a time and ignores persistence handoff", () => {
- const fakeWindow = installFakeWindow();
- const hooks = createHookHarness();
- const { TASK_PROGRESS_SEQUENCE_STEP_MS, useSequencedTaskProgress } = createTsModuleLoader({
- mocks: { react: hooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts");
- const initial = [update("todo-0", 0)];
- const batch = [...initial, update("todo-1", 1), update("todo-2", 2)];
-
- try {
- assert.equal(hooks.render(() => useSequencedTaskProgress(initial)).completedCount, 0);
- assert.equal(hooks.render(() => useSequencedTaskProgress(batch)).completedCount, 0);
- assert.equal(hooks.render(() => useSequencedTaskProgress(batch)).completedCount, 1);
- assert.deepEqual(fakeWindow.delays, [TASK_PROGRESS_SEQUENCE_STEP_MS]);
-
- fakeWindow.runNext();
- assert.equal(hooks.render(() => useSequencedTaskProgress(batch)).completedCount, 2);
- assert.equal(fakeWindow.size, 0);
-
- const duplicateSnapshot = [
- ...batch,
- { key: "anonymous-live-overlap", snapshot: snapshot(2) },
- ];
- assert.equal(hooks.render(() => useSequencedTaskProgress(duplicateSnapshot)).completedCount, 2);
- assert.equal(hooks.render(() => useSequencedTaskProgress(duplicateSnapshot)).completedCount, 2);
- assert.equal(fakeWindow.size, 0);
-
- assert.equal(hooks.render(() => useSequencedTaskProgress(initial)).completedCount, 2);
- assert.equal(hooks.render(() => useSequencedTaskProgress(batch)).completedCount, 2);
- assert.equal(fakeWindow.size, 0);
- } finally {
- hooks.unmount();
- fakeWindow.restore();
- }
-});
-
-test("GUI sequencer keeps the initial roster stable through shorter updates and history restore", () => {
- const fakeWindow = installFakeWindow();
- const hooks = createHookHarness();
- const { useSequencedTaskProgress } = createTsModuleLoader({
- mocks: { react: hooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts");
- const initialTodos = Array.from({ length: 12 }, (_, index) => ({
- content: `Task ${index + 1}`,
- activeForm: `Working ${index + 1}`,
- status: index === 0 ? "in_progress" : "pending",
- }));
- const initial = [{ key: "plan", snapshot: snapshotFromTodos(initialTodos) }];
- const shortened = {
- key: "status-1",
- snapshot: snapshotFromTodos(
- initialTodos.slice(0, 5).map((todo) => ({ ...todo, status: "completed" })),
- ),
- };
- const batch = [...initial, shortened];
-
- try {
- assert.equal(hooks.render(() => useSequencedTaskProgress(initial)).totalCount, 12);
- assert.equal(hooks.render(() => useSequencedTaskProgress(batch)).completedCount, 0);
- const displayed = hooks.render(() => useSequencedTaskProgress(batch));
- assert.equal(displayed.totalCount, 12);
- assert.equal(displayed.completedCount, 5);
- assert.deepEqual(
- displayed.todos.map((todo) => todo.content),
- initialTodos.map((todo) => todo.content),
- );
- assert.equal(fakeWindow.size, 0);
-
- const restoredHooks = createHookHarness();
- const restoredHook = createTsModuleLoader({
- mocks: { react: restoredHooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts").useSequencedTaskProgress;
- const restored = restoredHooks.render(() => restoredHook(batch, false));
- assert.equal(restored.totalCount, 12);
- assert.equal(restored.completedCount, 5);
- assert.equal(fakeWindow.size, 0);
- restoredHooks.unmount();
- } finally {
- hooks.unmount();
- fakeWindow.restore();
- }
-});
-
-test("GUI sequencer skips restored history replay, applies same-call changes, and clears immediately", () => {
- const fakeWindow = installFakeWindow();
- const hooks = createHookHarness();
- const { useSequencedTaskProgress } = createTsModuleLoader({
- mocks: { react: hooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts");
- const restored = [update("todo-0", 0), update("todo-1", 1), update("todo-2", 2)];
-
- try {
- assert.equal(hooks.render(() => useSequencedTaskProgress(restored)).completedCount, 2);
- assert.equal(fakeWindow.size, 0);
-
- const hydrationHooks = createHookHarness();
- const hydrationHook = createTsModuleLoader({
- mocks: { react: hydrationHooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts").useSequencedTaskProgress;
- assert.equal(hydrationHooks.render(() => hydrationHook([], false)), null);
- assert.equal(hydrationHooks.render(() => hydrationHook(restored, false)), null);
- assert.equal(hydrationHooks.render(() => hydrationHook(restored, false)).completedCount, 2);
- assert.equal(fakeWindow.size, 0);
- hydrationHooks.unmount();
-
- const revised = [{ key: "todo-2", snapshot: snapshot(1) }];
- const replacementHooks = createHookHarness();
- const replacementHook = createTsModuleLoader({
- mocks: { react: replacementHooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts").useSequencedTaskProgress;
- assert.equal(replacementHooks.render(() => replacementHook(revised)).completedCount, 1);
- const sameCallUpdated = [{ key: "todo-2", snapshot: snapshot(2) }];
- assert.equal(replacementHooks.render(() => replacementHook(sameCallUpdated)).completedCount, 1);
- assert.equal(replacementHooks.render(() => replacementHook(sameCallUpdated)).completedCount, 2);
- replacementHooks.unmount();
-
- const cleared = [...restored, { key: "todo-clear", snapshot: null }];
- assert.equal(hooks.render(() => useSequencedTaskProgress(cleared)), null);
- assert.equal(hooks.render(() => useSequencedTaskProgress(cleared)), null);
- assert.equal(fakeWindow.size, 0);
- } finally {
- hooks.unmount();
- fakeWindow.restore();
- }
-});
-
-test("GUI sequencer clears on a new user-turn boundary and starts the next plan fresh", () => {
- const fakeWindow = installFakeWindow();
- const hooks = createHookHarness();
- const { useSequencedTaskProgress } = createTsModuleLoader({
- mocks: { react: hooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts");
- const oldPlan = [update("old-todo", 2)];
- const boundary = [{ key: "user-turn:next", snapshot: null }];
- const nextPlan = [...boundary, update("new-todo", 0)];
-
- try {
- assert.equal(hooks.render(() => useSequencedTaskProgress(oldPlan)).completedCount, 2);
- assert.equal(hooks.render(() => useSequencedTaskProgress(boundary)), null);
- assert.equal(hooks.render(() => useSequencedTaskProgress(boundary)), null);
- assert.equal(hooks.render(() => useSequencedTaskProgress(nextPlan)), null);
- assert.equal(hooks.render(() => useSequencedTaskProgress(nextPlan)).completedCount, 0);
- assert.equal(fakeWindow.size, 0);
- } finally {
- hooks.unmount();
- fakeWindow.restore();
- }
-});
-
-test("GUI sequencer keeps partial argument frames hidden until the TodoWrite result settles", () => {
- const fakeWindow = installFakeWindow();
- const hooks = createHookHarness();
- const { TASK_PROGRESS_ARGUMENT_STABLE_MS, useSequencedTaskProgress } = createTsModuleLoader({
- mocks: { react: hooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts");
- const boundary = [{ key: "user-turn:new", snapshot: null }];
- const draft = (todos) => [
- ...boundary,
- { key: "todo-live", snapshot: snapshotFromTodos(todos), settled: false },
- ];
- const invalidDraft = [
- ...boundary,
- { key: "todo-live", snapshot: undefined, settled: false },
- ];
- const fullTodos = Array.from({ length: 12 }, (_, index) => ({
- content: `Task ${index + 1}`,
- activeForm: `Working ${index + 1}`,
- status: index === 0 ? "in_progress" : "pending",
- }));
-
- try {
- assert.equal(hooks.render(() => useSequencedTaskProgress(boundary)), null);
-
- assert.equal(hooks.render(() => useSequencedTaskProgress(draft(fullTodos.slice(0, 1)))), null);
- assert.equal(fakeWindow.size, 1);
- assert.equal(fakeWindow.delays.at(-1), TASK_PROGRESS_ARGUMENT_STABLE_MS);
-
- assert.equal(hooks.render(() => useSequencedTaskProgress(invalidDraft)), null);
- assert.equal(fakeWindow.size, 0);
-
- assert.equal(hooks.render(() => useSequencedTaskProgress(draft(fullTodos.slice(0, 4)))), null);
- assert.equal(fakeWindow.size, 1);
- assert.equal(hooks.render(() => useSequencedTaskProgress(invalidDraft)), null);
- assert.equal(fakeWindow.size, 0);
-
- const settled = [
- ...boundary,
- { key: "todo-live", snapshot: snapshotFromTodos(fullTodos), settled: true },
- ];
- assert.equal(hooks.render(() => useSequencedTaskProgress(settled)), null);
- const displayed = hooks.render(() => useSequencedTaskProgress(settled));
- assert.equal(displayed.totalCount, 12);
- assert.equal(fakeWindow.size, 0);
- } finally {
- hooks.unmount();
- fakeWindow.restore();
- }
-});
-
-test("GUI sequencer adopts a stable complete-arguments fallback when no result arrives", () => {
- const fakeWindow = installFakeWindow();
- const hooks = createHookHarness();
- const { useSequencedTaskProgress } = createTsModuleLoader({
- mocks: { react: hooks.react },
- }).loadModule("@liveagent/ui/components/chat/useSequencedTaskProgress.ts");
- const boundary = [{ key: "user-turn:fallback", snapshot: null }];
- const todos = Array.from({ length: 12 }, (_, index) => ({
- content: `Fallback ${index + 1}`,
- activeForm: `Working fallback ${index + 1}`,
- status: index === 0 ? "in_progress" : "pending",
- }));
- const completeArguments = [
- ...boundary,
- { key: "todo-fallback", snapshot: snapshotFromTodos(todos), settled: false },
- ];
-
- try {
- assert.equal(hooks.render(() => useSequencedTaskProgress(boundary)), null);
- assert.equal(hooks.render(() => useSequencedTaskProgress(completeArguments)), null);
- assert.equal(fakeWindow.size, 1);
- fakeWindow.runNext();
- assert.equal(
- hooks.render(() => useSequencedTaskProgress(completeArguments)).totalCount,
- 12,
- );
- } finally {
- hooks.unmount();
- fakeWindow.restore();
- }
-});
diff --git a/crates/agent-gui/test/chat/task-progress.test.mjs b/crates/agent-gui/test/chat/task-progress.test.mjs
index 8bd54a042..191fc3746 100644
--- a/crates/agent-gui/test/chat/task-progress.test.mjs
+++ b/crates/agent-gui/test/chat/task-progress.test.mjs
@@ -1,298 +1,127 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
-import { fileURLToPath } from "node:url";
import test from "node:test";
+import { fileURLToPath } from "node:url";
import { createTsModuleLoader } from "../helpers/load-ts-module.mjs";
const taskProgress = createTsModuleLoader().loadModule("@liveagent/ui/lib/chat/taskProgress.ts");
-const todo = (content, status, activeForm = content) => ({ content, status, activeForm });
+const task = (id, subject, status, activeForm = subject) => ({
+ id,
+ subject,
+ description: `${subject} completion criteria`,
+ activeForm,
+ status,
+});
const row = (blocks) => ({ kind: "assistant", rounds: [{ blocks }] });
const userRow = (key) => ({ kind: "user", key });
-const block = ({ args, details, id, isError = false, settled = true }) => ({
+const block = ({
+ id = "task-call",
+ name = "TaskUpdate",
+ tasks = [],
+ runId = "run-1",
+ revision = 1,
+ settled = true,
+ isError = false,
+ kind = "task_list",
+}) => ({
kind: "tool",
item: {
- toolCall: { id, name: "TodoWrite", arguments: args },
- toolResult: settled ? { isError, details } : undefined,
+ toolCall: { id, name, arguments: { taskId: "1", status: "completed" } },
+ toolResult: settled
+ ? { isError, details: { kind, action: "updated", runId, revision, tasks } }
+ : undefined,
},
});
-test("prefers result details and summarizes progress", () => {
- const todos = [todo("Inspect", "completed"), todo("Implement", "in_progress", "Working")];
- const snapshot = taskProgress.selectLatestTodoProgress([
- row([block({ args: { todos: [todo("stale", "pending")] }, details: { kind: "todo_write", todos } })]),
- ]);
- assert.deepEqual(snapshot.todos, todos);
- assert.deepEqual([snapshot.completedCount, snapshot.totalCount, snapshot.currentStep, snapshot.state], [1, 2, 2, "in_progress"]);
-});
-
-test("uses complete streaming arguments", () => {
- const historical = [todo("Previous", "completed")];
- const todos = [todo("Inspect", "completed"), todo("Implement", "pending")];
- assert.deepEqual(
- taskProgress.selectLatestTodoProgress(
- [row([block({ args: { todos: historical }, details: { kind: "todo_write", todos: historical } })])],
- [{ blocks: [block({ args: { todos }, settled: false })] }],
- ).todos,
- todos,
- );
-});
-
-test("distinguishes tentative, invalid, and settled TodoWrite frames", () => {
- const oneTodo = [todo("Task 1", "in_progress")];
- const twelveTodos = Array.from({ length: 12 }, (_, index) =>
- todo(`Task ${index + 1}`, index === 0 ? "in_progress" : "pending"),
- );
- const rowsWith = (todoBlock) => [userRow("new-turn"), row([todoBlock])];
-
- const tentative = taskProgress.selectTodoProgressUpdates(
- rowsWith(block({ id: "todo-live", args: { todos: oneTodo }, settled: false })),
- ).at(-1);
- assert.equal(tentative.settled, false);
- assert.equal(tentative.snapshot.totalCount, 1);
-
- const invalid = taskProgress.selectTodoProgressUpdates(
- rowsWith(
- block({ id: "todo-live", args: { todos: [{ content: "Partial" }] }, settled: false }),
- ),
- ).at(-1);
- assert.equal(invalid.settled, false);
- assert.equal(invalid.snapshot, undefined);
-
- const settled = taskProgress.selectTodoProgressUpdates(
- rowsWith(
- block({
- id: "todo-live",
- args: { todos: twelveTodos },
- details: { kind: "todo_write", todos: twelveTodos },
- }),
- ),
- ).at(-1);
- assert.equal(settled.settled, true);
- assert.equal(settled.snapshot.totalCount, 12);
-});
-
-test("identifies only TodoWrite tool blocks for transcript filtering", () => {
- assert.equal(taskProgress.isTodoWriteToolBlock(block({ args: { todos: [] }, settled: false })), true);
- assert.equal(
- taskProgress.isTodoWriteToolBlock({
- kind: "tool",
- item: { toolCall: { name: "Read", arguments: { path: "README.md" } } },
- }),
- false,
- );
-});
-
-test("GUI adapter projects live rounds without waiting for transcript persistence", () => {
- const source = readFileSync(
- fileURLToPath(new URL("../../src/pages/ChatPage.tsx", import.meta.url)),
- "utf8",
- );
- assert.match(source, /liveTranscriptStore\.subscribe/);
- assert.match(source, /selectTodoProgressUpdates\(historyItems, liveRounds\)/);
- assert.match(source, /useSequencedTaskProgress\(updates, isConversationRunning\)/);
- assert.match(source, /key=\{currentConversationId\}/);
- assert.match(source, / {
- const first = [todo("One", "in_progress", "Working one"), todo("Two", "pending")];
- const second = [todo("One", "completed"), todo("Two", "in_progress", "Working two")];
- const updates = taskProgress.selectTodoProgressUpdates(
- [
- row([
- block({
- id: "todo-1",
- args: { todos: first },
- details: { kind: "todo_write", todos: first },
- }),
- ]),
- ],
+test("projects only the latest successful canonical task snapshot", () => {
+ const first = [task("1", "Inspect", "in_progress", "Inspecting")];
+ const second = [
+ task("1", "Inspect", "completed", "Inspecting"),
+ task("2", "Implement", "in_progress", "Implementing"),
+ ];
+ const snapshot = taskProgress.selectLatestTaskProgress(
+ [row([block({ id: "create", name: "TaskCreate", tasks: first })])],
[
{
blocks: [
- block({
- id: "todo-1",
- args: { todos: first },
- details: { kind: "todo_write", todos: first },
- }),
- block({
- id: "todo-2",
- args: { todos: second },
- details: { kind: "todo_write", todos: second },
- }),
+ block({ id: "create", name: "TaskCreate", tasks: first }),
+ block({ id: "update", tasks: second, revision: 2 }),
],
},
],
);
- assert.deepEqual(
- updates.map((update) => [update.key, update.snapshot.completedCount]),
- [
- ["todo-1", 0],
- ["todo-2", 1],
- ],
- );
-});
-
-test("a submitted user turn hides the old plan until a new TodoWrite starts", () => {
- const oldTodos = [todo("Old task", "completed")];
- const oldBlock = block({
- id: "old-todo",
- args: { todos: oldTodos },
- details: { kind: "todo_write", todos: oldTodos },
- });
- const hiddenUpdates = taskProgress.selectTodoProgressUpdates(
- [row([oldBlock]), userRow("next-message")],
- [{ blocks: [oldBlock] }],
- );
+ assert.deepEqual(snapshot.tasks, second);
assert.deepEqual(
- hiddenUpdates.map((update) => [update.key, update.snapshot]),
- [["user-turn:next-message", null]],
+ [snapshot.runId, snapshot.revision, snapshot.completedCount, snapshot.currentStep, snapshot.state],
+ ["run-1", 2, 1, 2, "in_progress"],
);
- assert.equal(
- taskProgress.selectLatestTodoProgress([row([oldBlock]), userRow("next-message")]),
- null,
- );
-
- const newTodos = [todo("New task", "in_progress", "Working new task")];
- const resumedUpdates = taskProgress.selectTodoProgressUpdates([
- row([oldBlock]),
- userRow("next-message"),
- row([
- block({
- id: "new-todo",
- args: { todos: newTodos },
- details: { kind: "todo_write", todos: newTodos },
- }),
- ]),
- ]);
- const resumedPlan = taskProgress.foldTodoProgressUpdates(resumedUpdates);
- assert.deepEqual(
- resumedUpdates.map((update) => update.key),
- ["user-turn:next-message", "new-todo"],
- );
- assert.deepEqual(resumedPlan.snapshot.todos, newTodos);
-});
-
-test("partial and failed updates preserve the previous snapshot", () => {
- const todos = [todo("Stable", "in_progress", "Working")];
- const snapshot = taskProgress.selectLatestTodoProgress([
- row([block({ args: { todos }, details: { kind: "todo_write", todos } })]),
- row([
- block({ args: { todos: [{ content: "Partial" }] }, settled: false }),
- block({ args: { todos: [todo("Failed", "pending")] }, isError: true }),
- ]),
- ]);
- assert.deepEqual(snapshot.todos, todos);
});
-test("invalid settled results do not fall back to arguments", () => {
- const stable = [todo("Stable", "in_progress", "Working")];
- const replacement = [todo("Untrusted", "pending")];
- const snapshot = taskProgress.selectLatestTodoProgress([
- row([block({ args: { todos: stable }, details: { kind: "todo_write", todos: stable } })]),
+test("ignores streaming arguments, failed results, and malformed snapshots", () => {
+ const stable = [task("1", "Stable", "in_progress", "Working")];
+ const snapshot = taskProgress.selectLatestTaskProgress([
+ row([block({ tasks: stable })]),
row([
+ block({ id: "streaming", tasks: [task("2", "Untrusted", "pending")], settled: false }),
+ block({ id: "failed", tasks: [task("2", "Failed", "pending")], isError: true }),
+ block({ id: "wrong-kind", tasks: [task("2", "Wrong", "pending")], kind: "other" }),
block({
- args: { todos: replacement },
- details: { kind: "unexpected", todos: replacement },
- }),
- block({
- args: { todos: replacement },
- details: { kind: "todo_write", todos: [{ content: "Partial" }] },
+ id: "malformed",
+ tasks: [{ id: "2", subject: "Partial", status: "pending" }],
}),
]),
]);
- assert.deepEqual(snapshot.todos, stable);
+
+ assert.deepEqual(snapshot.tasks, stable);
});
-test("empty clears and invalid snapshots are ignored", () => {
- const active = [todo("Old", "pending")];
- assert.equal(
- taskProgress.selectLatestTodoProgress([
- row([block({ args: { todos: active }, details: { kind: "todo_write", todos: active } })]),
- row([block({ args: { todos: [] }, details: { kind: "todo_write", todos: [] } })]),
- ]),
- null,
- );
+test("a new user run clears old progress until a new canonical snapshot arrives", () => {
+ const oldTasks = [task("1", "Old", "completed")];
+ const newTasks = [task("1", "New", "pending")];
assert.equal(
- taskProgress.readCompleteTodoList([todo("One", "in_progress"), todo("Two", "in_progress")]),
+ taskProgress.selectLatestTaskProgress([row([block({ tasks: oldTasks })]), userRow("next")]),
null,
);
-});
-
-test("locks the confirmed plan roster while later calls merge only task statuses", () => {
- const initialTodos = Array.from({ length: 12 }, (_, index) =>
- todo(`Task ${index + 1}`, index === 0 ? "in_progress" : "pending", `Working ${index + 1}`),
- );
- const initialSnapshot = taskProgress.createTodoProgressSnapshot(initialTodos);
- let plan = taskProgress.applyTodoProgressUpdate(
- { anchorKey: null, snapshot: null },
- { key: "initial-plan", snapshot: initialSnapshot },
- );
-
- const shorterUpdate = taskProgress.createTodoProgressSnapshot(
- initialTodos.slice(0, 5).map((item) => ({ ...item, status: "completed" })),
- );
- plan = taskProgress.applyTodoProgressUpdate(plan, {
- key: "status-update-1",
- snapshot: shorterUpdate,
- });
-
- assert.equal(plan.snapshot.totalCount, 12);
- assert.equal(plan.snapshot.completedCount, 5);
assert.deepEqual(
- plan.snapshot.todos.map((item) => item.content),
- initialTodos.map((item) => item.content),
+ taskProgress.selectLatestTaskProgress([
+ row([block({ tasks: oldTasks })]),
+ userRow("next"),
+ row([block({ name: "TaskCreate", runId: "run-2", tasks: newTasks })]),
+ ]).tasks,
+ newTasks,
);
+});
- const rewrittenFullUpdate = taskProgress.createTodoProgressSnapshot(
- initialTodos.map((item, index) =>
- todo(
- `Rewritten ${index + 1}`,
- index < 5 ? "completed" : index === 5 ? "in_progress" : "pending",
- ),
- ),
- );
- plan = taskProgress.applyTodoProgressUpdate(plan, {
- key: "status-update-2",
- snapshot: rewrittenFullUpdate,
- });
-
- assert.equal(plan.snapshot.totalCount, 12);
- assert.equal(plan.snapshot.currentStep, 6);
- assert.equal(plan.snapshot.todos[5].status, "in_progress");
- assert.deepEqual(
- plan.snapshot.todos.map((item) => item.content),
- initialTodos.map((item) => item.content),
+test("an empty successful TaskList clears the progress indicator", () => {
+ assert.equal(
+ taskProgress.selectLatestTaskProgress([
+ row([block({ tasks: [task("1", "Active", "pending")] })]),
+ row([block({ name: "TaskList", tasks: [], revision: 0 })]),
+ ]),
+ null,
);
});
-test("allows the anchor call to finish its roster, then uses empty as the next plan boundary", () => {
- const provisional = taskProgress.createTodoProgressSnapshot([
- todo("One", "in_progress"),
- todo("Two", "pending"),
- ]);
- const confirmed = taskProgress.createTodoProgressSnapshot([
- todo("One", "in_progress"),
- todo("Two", "pending"),
- todo("Three", "pending"),
- ]);
- const nextPlan = taskProgress.createTodoProgressSnapshot([todo("Fresh", "pending")]);
- const plan = taskProgress.foldTodoProgressUpdates([
- { key: "initial-plan", snapshot: provisional },
- { key: "initial-plan", snapshot: confirmed },
- { key: "clear", snapshot: null },
- { key: "next-plan", snapshot: nextPlan },
- ]);
-
- assert.equal(plan.anchorKey, "next-plan");
- assert.deepEqual(plan.snapshot.todos, nextPlan.todos);
+test("all task tools are standalone transcript-hidden blocks", () => {
+ for (const name of ["TaskCreate", "TaskUpdate", "TaskList"]) {
+ assert.equal(taskProgress.isTaskToolBlock(block({ name, settled: false })), true);
+ }
+ assert.equal(
+ taskProgress.isTaskToolBlock({
+ kind: "tool",
+ item: { toolCall: { name: "Read", arguments: { path: "README.md" } } },
+ }),
+ false,
+ );
});
-test("completed lists report the final step", () => {
- const snapshot = taskProgress.createTodoProgressSnapshot([
- todo("One", "completed"),
- todo("Two", "completed"),
- ]);
- assert.deepEqual([snapshot.completedCount, snapshot.currentStep, snapshot.state], [2, 2, "completed"]);
+test("GUI projects canonical live results without a sequencing compatibility layer", () => {
+ const source = readFileSync(
+ fileURLToPath(new URL("../../src/pages/ChatPage.tsx", import.meta.url)),
+ "utf8",
+ );
+ assert.match(source, /liveTranscriptStore\.subscribe/);
+ assert.match(source, /selectLatestTaskProgress\(historyItems, liveRounds\)/);
+ assert.match(source, /key=\{currentConversationId\}/);
});
diff --git a/crates/agent-gui/test/chat/transcript-row-model.test.mjs b/crates/agent-gui/test/chat/transcript-row-model.test.mjs
index b00020622..1e27ae213 100644
--- a/crates/agent-gui/test/chat/transcript-row-model.test.mjs
+++ b/crates/agent-gui/test/chat/transcript-row-model.test.mjs
@@ -291,7 +291,7 @@ test("terminal settlement removes the live tail before sending clears", () => {
assert.equal(nextPending.rows[2].units.at(-1).mutable, true);
});
-test("assistant rounds hide TodoWrite while preserving grouped top-level render units", () => {
+test("assistant rounds hide task tools while preserving grouped top-level render units", () => {
const model = createTranscriptRowModel();
const tool = (id, name = "Read") => ({
kind: "tool",
@@ -304,7 +304,7 @@ test("assistant rounds hide TodoWrite while preserving grouped top-level render
blocks: [
{ kind: "text", id: "text-1", text: "answer" },
{ kind: "thinking", id: "thinking-1", text: "thought" },
- tool("todo-1", "TodoWrite"),
+ tool("task-1", "TaskCreate"),
tool("call-1"),
tool("call-2"),
{ kind: "hostedSearch", item: { id: "search-1" } },
diff --git a/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs b/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs
index 64395a641..2a4421719 100644
--- a/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs
+++ b/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs
@@ -394,7 +394,7 @@ test("result details round-trip through the transcript parser", () => {
assert.equal(parsed.answers.length, 2);
assert.equal(parsed.cancelled, false);
- assert.equal(shared.parseAskUserQuestionResultDetails({ kind: "todo_write" }), null);
+ assert.equal(shared.parseAskUserQuestionResultDetails({ kind: "task_list" }), null);
assert.equal(shared.parseAskUserQuestionResultDetails(null), null);
});
diff --git a/crates/agent-gui/test/tools/task-tools.test.mjs b/crates/agent-gui/test/tools/task-tools.test.mjs
new file mode 100644
index 000000000..5d3bc45d5
--- /dev/null
+++ b/crates/agent-gui/test/tools/task-tools.test.mjs
@@ -0,0 +1,243 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { validateToolArguments } from "@earendil-works/pi-ai";
+import * as typebox from "typebox";
+import { createTsModuleLoader } from "../helpers/load-ts-module.mjs";
+
+function toolCall(name, argumentsValue = {}, id = `call-${name}`) {
+ return { type: "toolCall", id, name, arguments: argumentsValue };
+}
+
+function createStore(overrides = {}) {
+ let state;
+ const commits = [];
+ return {
+ store: {
+ runId: "run-stable",
+ getState: () => state,
+ commitState: async (nextState) => {
+ await overrides.beforeCommit?.(nextState);
+ state = nextState;
+ commits.push(nextState);
+ },
+ },
+ getState: () => state,
+ commits,
+ };
+}
+
+function loadTaskTools(options = {}) {
+ return createTsModuleLoader({ mocks: { typebox }, ...options }).loadModule(
+ "src/lib/tools/taskTools.ts",
+ );
+}
+
+const createArgs = (subject) => ({
+ subject,
+ description: `${subject} completion criteria`,
+ activeForm: `${subject} in progress`,
+});
+
+test("TaskCreate schema requires the complete task description", () => {
+ const { createTaskTools } = loadTaskTools();
+ const { store } = createStore();
+ const createTool = createTaskTools(store).tools.find((tool) => tool.name === "TaskCreate");
+ assert.ok(createTool);
+ assert.deepEqual(
+ validateToolArguments(createTool, toolCall("TaskCreate", createArgs("Inspect"))),
+ createArgs("Inspect"),
+ );
+ assert.throws(() =>
+ validateToolArguments(
+ createTool,
+ toolCall("TaskCreate", { subject: "Inspect", description: "Inspect files" }),
+ ),
+ );
+});
+
+test("TaskCreate allocates stable monotonic IDs and revisions", async () => {
+ const { createTaskTools } = loadTaskTools();
+ const harness = createStore();
+ const bundle = createTaskTools(harness.store);
+
+ const first = await bundle.executeToolCall(toolCall("TaskCreate", createArgs("Inspect"), "c1"));
+ const second = await bundle.executeToolCall(
+ toolCall("TaskCreate", createArgs("Implement"), "c2"),
+ );
+
+ assert.equal(first.isError, false);
+ assert.equal(second.isError, false);
+ assert.deepEqual(
+ harness.getState().tasks.map((task) => task.id),
+ ["1", "2"],
+ );
+ assert.equal(harness.getState().revision, 2);
+ assert.equal(harness.getState().nextTaskId, 3);
+ assert.deepEqual(second.details.tasks, harness.getState().tasks);
+});
+
+test("parallel TaskCreate calls are serialized before allocating IDs", async () => {
+ const { createTaskTools } = loadTaskTools();
+ const harness = createStore({ beforeCommit: () => new Promise((resolve) => setImmediate(resolve)) });
+ const bundle = createTaskTools(harness.store);
+
+ const results = await Promise.all([
+ bundle.executeToolCall(toolCall("TaskCreate", createArgs("One"), "parallel-1")),
+ bundle.executeToolCall(toolCall("TaskCreate", createArgs("Two"), "parallel-2")),
+ bundle.executeToolCall(toolCall("TaskCreate", createArgs("Three"), "parallel-3")),
+ ]);
+
+ assert.ok(results.every((result) => result.isError === false));
+ assert.deepEqual(
+ harness.getState().tasks.map((task) => task.id),
+ ["1", "2", "3"],
+ );
+ assert.deepEqual(
+ harness.commits.map((state) => state.revision),
+ [1, 2, 3],
+ );
+});
+
+test("TaskUpdate changes one stable task and enforces one in_progress task", async () => {
+ const { createTaskTools } = loadTaskTools();
+ const harness = createStore();
+ const bundle = createTaskTools(harness.store);
+ await bundle.executeToolCall(toolCall("TaskCreate", createArgs("One"), "create-1"));
+ await bundle.executeToolCall(toolCall("TaskCreate", createArgs("Two"), "create-2"));
+
+ const started = await bundle.executeToolCall(
+ toolCall("TaskUpdate", { taskId: "1", status: "in_progress" }, "start-1"),
+ );
+ const rejected = await bundle.executeToolCall(
+ toolCall("TaskUpdate", { taskId: "2", status: "in_progress" }, "start-2"),
+ );
+ const completed = await bundle.executeToolCall(
+ toolCall("TaskUpdate", { taskId: "1", status: "completed" }, "complete-1"),
+ );
+
+ assert.equal(started.isError, false);
+ assert.equal(rejected.isError, true);
+ assert.match(rejected.content[0].text, /already in_progress/);
+ assert.equal(completed.isError, false);
+ assert.deepEqual(
+ harness.getState().tasks.map(({ id, status }) => ({ id, status })),
+ [
+ { id: "1", status: "completed" },
+ { id: "2", status: "pending" },
+ ],
+ );
+});
+
+test("TaskList returns a complete canonical snapshot without mutating revision", async () => {
+ const { createTaskTools } = loadTaskTools();
+ const harness = createStore();
+ const bundle = createTaskTools(harness.store);
+ await bundle.executeToolCall(toolCall("TaskCreate", createArgs("Inspect"), "create"));
+
+ const listed = await bundle.executeToolCall(toolCall("TaskList", {}, "list"));
+
+ assert.equal(listed.isError, false);
+ assert.equal(listed.details.kind, "task_list");
+ assert.equal(listed.details.action, "listed");
+ assert.equal(listed.details.runId, "run-stable");
+ assert.equal(listed.details.revision, 1);
+ assert.deepEqual(listed.details.tasks, harness.getState().tasks);
+ assert.equal(harness.commits.length, 1);
+});
+
+test("a failed durable commit is reported as an error and never advances state", async () => {
+ const { createTaskTools } = loadTaskTools();
+ const harness = createStore({
+ beforeCommit: async () => {
+ throw new Error("database unavailable");
+ },
+ });
+ const result = await createTaskTools(harness.store).executeToolCall(
+ toolCall("TaskCreate", createArgs("Inspect")),
+ );
+
+ assert.equal(result.isError, true);
+ assert.match(result.content[0].text, /database unavailable/);
+ assert.equal(harness.getState(), undefined);
+ assert.equal(harness.commits.length, 0);
+});
+
+test("runtime context serializes the authoritative run, revision, IDs, and task text", () => {
+ const { formatTaskListRuntimeContext } = loadTaskTools();
+ const state = {
+ runId: 'run-"',
+ revision: 7,
+ nextTaskId: 3,
+ tasks: [
+ {
+ id: "1",
+ subject: "Inspect ",
+ description: "Keep the same task after compaction",
+ activeForm: "Inspecting state",
+ status: "in_progress",
+ },
+ ],
+ };
+ const prompt = formatTaskListRuntimeContext(state);
+
+ assert.match(prompt, /Authoritative Task Runtime State/);
+ assert.match(prompt, /"runId":"run-\\""/);
+ assert.match(prompt, /"revision":7/);
+ assert.match(prompt, /"id":"1"/);
+ assert.match(prompt, /Do not recreate, renumber, reorder, or replace/);
+ assert.equal(formatTaskListRuntimeContext(undefined), "");
+});
+
+test("stored task state parser rejects duplicate IDs and multiple active tasks", () => {
+ const { parseTaskListState } = createTsModuleLoader().loadModule(
+ "src/lib/tools/taskState.ts",
+ );
+ const task = {
+ id: "1",
+ subject: "Inspect",
+ description: "Inspect files",
+ activeForm: "Inspecting",
+ status: "in_progress",
+ };
+ assert.throws(() =>
+ parseTaskListState({ runId: "run", revision: 1, nextTaskId: 2, tasks: [task, task] }),
+ );
+ assert.throws(() =>
+ parseTaskListState({
+ runId: "run",
+ revision: 2,
+ nextTaskId: 3,
+ tasks: [task, { ...task, id: "2" }],
+ }),
+ );
+});
+
+test("conversation state preserves tasks across appends and clears them only for a new run", () => {
+ const conversationState = createTsModuleLoader().loadModule(
+ "src/lib/chat/conversation/conversationState.ts",
+ );
+ const taskList = {
+ runId: "run-current",
+ revision: 1,
+ nextTaskId: 2,
+ tasks: [
+ {
+ id: "1",
+ subject: "Inspect",
+ description: "Inspect files",
+ activeForm: "Inspecting files",
+ status: "in_progress",
+ },
+ ],
+ };
+ const initial = conversationState.setTaskListState(
+ conversationState.createConversationStateFromContext({ systemPrompt: "sys", messages: [] }),
+ taskList,
+ );
+ const appended = conversationState.appendMessagesToConversation(initial, [
+ { role: "user", id: "resume", content: "continue", timestamp: 1 },
+ ]);
+
+ assert.deepEqual(appended.meta.taskList, taskList);
+ assert.equal(conversationState.clearTaskListState(appended).meta.taskList, undefined);
+});
diff --git a/crates/agent-gui/test/tools/todo-tools.test.mjs b/crates/agent-gui/test/tools/todo-tools.test.mjs
deleted file mode 100644
index afc0f4e6c..000000000
--- a/crates/agent-gui/test/tools/todo-tools.test.mjs
+++ /dev/null
@@ -1,396 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-import path from "node:path";
-import { fileURLToPath } from "node:url";
-import { validateToolArguments } from "@earendil-works/pi-ai";
-import * as typebox from "typebox";
-import { createTsModuleLoader } from "../helpers/load-ts-module.mjs";
-import { createFakeStoreIpc } from "../subagents/harness.mjs";
-
-const rootDir = path.resolve(fileURLToPath(new URL("../..", import.meta.url)));
-const agentRunnerModulePath = path.join(rootDir, "src/lib/chat/runner/agentRunner.ts");
-
-function createAssistant(text) {
- return {
- role: "assistant",
- content: [{ type: "text", text }],
- api: "openai-responses",
- provider: "openai",
- model: "gpt-5",
- stopReason: "stop",
- timestamp: Date.now(),
- };
-}
-
-function createAgentToolCall(argumentsValue, id = "call-agent") {
- return { type: "toolCall", id, name: "Agent", arguments: argumentsValue };
-}
-
-function createTodoToolCall(argumentsValue, id = "call-todo") {
- return { type: "toolCall", id, name: "TodoWrite", arguments: argumentsValue };
-}
-
-function loadTodoTools() {
- const loader = createTsModuleLoader();
- return loader.loadModule("src/lib/tools/todoTools.ts");
-}
-
-test("TodoWrite schema accepts a well-formed todos array", () => {
- const loader = createTsModuleLoader({ mocks: { typebox } });
- const { createTodoTools, createTodoToolState } = loader.loadModule("src/lib/tools/todoTools.ts");
- const bundle = createTodoTools({ state: createTodoToolState() });
- const tool = bundle.tools.find((candidate) => candidate.name === "TodoWrite");
- assert.ok(tool);
-
- const args = validateToolArguments(
- tool,
- createTodoToolCall({
- todos: [{ content: "Run tests", status: "pending", activeForm: "Running tests" }],
- }),
- );
- assert.deepEqual(args, {
- todos: [{ content: "Run tests", status: "pending", activeForm: "Running tests" }],
- });
-});
-
-test("TodoWrite schema rejects a todo item missing content", () => {
- const loader = createTsModuleLoader({ mocks: { typebox } });
- const { createTodoTools, createTodoToolState } = loader.loadModule("src/lib/tools/todoTools.ts");
- const bundle = createTodoTools({ state: createTodoToolState() });
- const tool = bundle.tools.find((candidate) => candidate.name === "TodoWrite");
-
- assert.throws(() =>
- validateToolArguments(
- tool,
- createTodoToolCall({
- todos: [{ status: "pending", activeForm: "Running tests" }],
- }),
- ),
- );
-});
-
-test("TodoWrite schema rejects a todo item missing status", () => {
- const loader = createTsModuleLoader({ mocks: { typebox } });
- const { createTodoTools, createTodoToolState } = loader.loadModule("src/lib/tools/todoTools.ts");
- const bundle = createTodoTools({ state: createTodoToolState() });
- const tool = bundle.tools.find((candidate) => candidate.name === "TodoWrite");
-
- assert.throws(() =>
- validateToolArguments(
- tool,
- createTodoToolCall({
- todos: [{ content: "Run tests", activeForm: "Running tests" }],
- }),
- ),
- );
-});
-
-test("TodoWrite schema rejects a todo item missing activeForm", () => {
- const loader = createTsModuleLoader({ mocks: { typebox } });
- const { createTodoTools, createTodoToolState } = loader.loadModule("src/lib/tools/todoTools.ts");
- const bundle = createTodoTools({ state: createTodoToolState() });
- const tool = bundle.tools.find((candidate) => candidate.name === "TodoWrite");
-
- assert.throws(() =>
- validateToolArguments(
- tool,
- createTodoToolCall({
- todos: [{ content: "Run tests", status: "pending" }],
- }),
- ),
- );
-});
-
-test("TodoWrite schema rejects an invalid status literal", () => {
- const loader = createTsModuleLoader({ mocks: { typebox } });
- const { createTodoTools, createTodoToolState } = loader.loadModule("src/lib/tools/todoTools.ts");
- const bundle = createTodoTools({ state: createTodoToolState() });
- const tool = bundle.tools.find((candidate) => candidate.name === "TodoWrite");
-
- assert.throws(() =>
- validateToolArguments(
- tool,
- createTodoToolCall({
- todos: [{ content: "Run tests", status: "done", activeForm: "Running tests" }],
- }),
- ),
- );
-});
-
-test("TodoWrite schema rejects a non-array todos value", () => {
- const loader = createTsModuleLoader({ mocks: { typebox } });
- const { createTodoTools, createTodoToolState } = loader.loadModule("src/lib/tools/todoTools.ts");
- const bundle = createTodoTools({ state: createTodoToolState() });
- const tool = bundle.tools.find((candidate) => candidate.name === "TodoWrite");
-
- assert.throws(() =>
- validateToolArguments(tool, createTodoToolCall({ todos: "not-an-array" })),
- );
-});
-
-test("executor stores a valid full todo list and reports isError: false", async () => {
- const { createTodoTools, createTodoToolState } = loadTodoTools();
- const state = createTodoToolState();
- const bundle = createTodoTools({ state });
- const todos = [
- { content: "Run tests", status: "in_progress", activeForm: "Running tests" },
- { content: "Ship release", status: "pending", activeForm: "Shipping release" },
- ];
-
- const result = await bundle.executeToolCall(createTodoToolCall({ todos }));
-
- assert.equal(result.isError, false);
- assert.equal(result.details.kind, "todo_write");
- assert.deepEqual(result.details.todos, todos);
- assert.deepEqual(state.getTodos(), todos);
-});
-
-test("executor replaces rather than merges on a second full-replacement call", async () => {
- const { createTodoTools, createTodoToolState } = loadTodoTools();
- const state = createTodoToolState();
- const bundle = createTodoTools({ state });
-
- await bundle.executeToolCall(
- createTodoToolCall({
- todos: [
- { content: "Run tests", status: "in_progress", activeForm: "Running tests" },
- { content: "Ship release", status: "pending", activeForm: "Shipping release" },
- ],
- }),
- );
-
- const secondTodos = [
- { content: "Ship release", status: "in_progress", activeForm: "Shipping release" },
- ];
- const result = await bundle.executeToolCall(createTodoToolCall({ todos: secondTodos }));
-
- assert.equal(result.isError, false);
- assert.deepEqual(state.getTodos(), secondTodos);
-});
-
-test("executor rejects a call with more than one in_progress item", async () => {
- const { createTodoTools, createTodoToolState } = loadTodoTools();
- const state = createTodoToolState();
- const bundle = createTodoTools({ state });
-
- const result = await bundle.executeToolCall(
- createTodoToolCall({
- todos: [
- { content: "Run tests", status: "in_progress", activeForm: "Running tests" },
- { content: "Ship release", status: "in_progress", activeForm: "Shipping release" },
- ],
- }),
- );
-
- assert.equal(result.isError, true);
- const text = result.content[0].text;
- assert.match(text, /in_progress/);
- assert.match(text, /one at a time|only one/i);
- // A rejected call must not clobber whatever was previously stored.
- assert.deepEqual(state.getTodos(), []);
-});
-
-test("executor rejects a malformed todos structure", async () => {
- const { createTodoTools, createTodoToolState } = loadTodoTools();
- const state = createTodoToolState();
- const bundle = createTodoTools({ state });
-
- const result = await bundle.executeToolCall(
- createTodoToolCall({
- todos: [{ content: "Run tests", status: "pending" }],
- }),
- );
-
- assert.equal(result.isError, true);
- assert.deepEqual(state.getTodos(), []);
-});
-
-test("getOrCreateTodoToolState returns the same state for a conversation and a fresh one after dispose", () => {
- const { getOrCreateTodoToolState, disposeTodoToolState } = loadTodoTools();
-
- const first = getOrCreateTodoToolState("conversation-todo-1");
- first.setTodos([{ content: "Run tests", status: "pending", activeForm: "Running tests" }]);
-
- const second = getOrCreateTodoToolState("conversation-todo-1");
- assert.equal(second, first);
- assert.deepEqual(second.getTodos(), [
- { content: "Run tests", status: "pending", activeForm: "Running tests" },
- ]);
-
- disposeTodoToolState("conversation-todo-1");
- const third = getOrCreateTodoToolState("conversation-todo-1");
- assert.notEqual(third, first);
- assert.deepEqual(third.getTodos(), []);
-});
-
-const DOCS_SERVER = {
- id: "docs",
- enabled: true,
- transport: "stdio",
- command: "mock-mcp-server",
- args: [],
- env: {},
-};
-
-function createRegistryHarness() {
- const runnerCalls = [];
- const loader = createTsModuleLoader({
- mocks: {
- [agentRunnerModulePath]: {
- async runAssistantWithTools(params) {
- runnerCalls.push(params);
- params.onTurnStart?.(1);
- const assistant = createAssistant("subagent done");
- return { assistant, messages: [assistant], emittedMessages: [assistant] };
- },
- },
- "@tauri-apps/api/path": {
- async homeDir() {
- return "/Users/test";
- },
- },
- "@tauri-apps/api/core": {
- async invoke(command, args) {
- if (command === "mcp_list_tools") {
- return [];
- }
- if (command === "subagent_worktree_create") {
- return {
- repoRoot: "/repo",
- worktreeRoot: "/tmp/liveagent-subagents/agent-a",
- workdir: "/tmp/liveagent-subagents/agent-a",
- branchName: "liveagent/subagent/agent-a",
- };
- }
- if (command === "subagent_worktree_status") {
- return {
- changed: false,
- status: "",
- diffStat: "",
- diff: "",
- diffTruncated: false,
- untrackedFiles: [],
- };
- }
- if (command === "subagent_worktree_cleanup") {
- return {
- worktreeRoot: args.input.worktreeRoot,
- branchName: args.input.branchName,
- removed: true,
- branchDeleted: true,
- };
- }
- throw new Error(`Unexpected invoke: ${command}`);
- },
- },
- },
- });
- return { loader, runnerCalls };
-}
-
-async function buildRegistry(
- harness,
- { withSubagentRuntime, runtimeScope = "chat", withTodoState = true, storeIpc } = {},
-) {
- const { loader } = harness;
- const { buildBuiltinToolRegistry } = loader.loadModule("src/lib/tools/builtinRegistry.ts");
- const { createFileToolState } = loader.loadModule("src/lib/tools/fileToolState.ts");
- const { createTodoToolState } = loader.loadModule("src/lib/tools/todoTools.ts");
- const mcpSettingsHolder = { value: { selected: [], servers: [DOCS_SERVER] } };
- const baseParams = {
- workdir: "/tmp/liveagent-todo-registry-test",
- providerId: "codex",
- fileState: createFileToolState(),
- skillsEnabled: true,
- runtimeScope,
- getMcpSettings: () => mcpSettingsHolder.value,
- ...(withTodoState ? { todoState: createTodoToolState() } : {}),
- };
- if (!withSubagentRuntime) {
- return { registry: await buildBuiltinToolRegistry(baseParams), mcpSettingsHolder };
- }
-
- const storeModule = loader.loadModule("src/lib/subagents/store.ts");
- const schedulerModule = loader.loadModule("src/lib/subagents/scheduler.ts");
- const ipc = storeIpc ?? createFakeStoreIpc();
- const store = storeModule.createSubagentConversationStore({
- conversationId: "conversation-1",
- ipc,
- });
- const registry = await buildBuiltinToolRegistry({
- ...baseParams,
- subagentRuntime: {
- providerId: "codex",
- model: "gpt-5",
- runtime: { baseUrl: "https://api.example.test/v1", apiKey: "test-key" },
- sessionId: "parent-session",
- templates: [],
- store,
- scheduler: schedulerModule.createSubagentScheduler(),
- },
- });
- return { registry, store, ipc, mcpSettingsHolder };
-}
-
-test("chat-scope registry with todoState includes TodoWrite, with or without a subagent runtime", async () => {
- const harnessNoSubagent = createRegistryHarness();
- const { registry: registryNoSubagent } = await buildRegistry(harnessNoSubagent, {
- withSubagentRuntime: false,
- });
- assert.ok(registryNoSubagent.tools.map((tool) => tool.name).includes("TodoWrite"));
-
- const harnessWithSubagent = createRegistryHarness();
- const { registry: registryWithSubagent } = await buildRegistry(harnessWithSubagent, {
- withSubagentRuntime: true,
- });
- assert.ok(registryWithSubagent.tools.map((tool) => tool.name).includes("TodoWrite"));
-});
-
-test("chat-scope registry without a todoState does not include TodoWrite", async () => {
- const harness = createRegistryHarness();
- const { registry } = await buildRegistry(harness, {
- withSubagentRuntime: false,
- withTodoState: false,
- });
- assert.ok(!registry.tools.map((tool) => tool.name).includes("TodoWrite"));
-});
-
-test("cron_auto_prompt scope registry never includes TodoWrite, even with a todoState", async () => {
- const harness = createRegistryHarness();
- const { registry } = await buildRegistry(harness, {
- withSubagentRuntime: false,
- runtimeScope: "cron_auto_prompt",
- withTodoState: true,
- });
- assert.ok(!registry.tools.map((tool) => tool.name).includes("TodoWrite"));
-});
-
-test("worktree subagent children never receive TodoWrite", async () => {
- const harness = createRegistryHarness();
- const { registry } = await buildRegistry(harness, { withSubagentRuntime: true });
-
- const result = await registry.executeToolCall(
- createAgentToolCall({
- agents: [{ id: "agent-a", prompt: "Plan the work.", mode: "worktree" }],
- }),
- );
- assert.equal(result.isError, false);
- assert.equal(harness.runnerCalls.length, 1);
- const names = harness.runnerCalls[0].tools.map((tool) => tool.name);
- assert.ok(!names.includes("TodoWrite"));
-});
-
-test("readonly subagent children never receive TodoWrite", async () => {
- const harness = createRegistryHarness();
- const { registry } = await buildRegistry(harness, { withSubagentRuntime: true });
-
- const result = await registry.executeToolCall(
- createAgentToolCall({
- agents: [{ id: "agent-b", prompt: "Investigate the code.", mode: "readonly" }],
- }),
- );
- assert.equal(result.isError, false);
- assert.equal(harness.runnerCalls.length, 1);
- const names = harness.runnerCalls[0].tools.map((tool) => tool.name);
- assert.ok(!names.includes("TodoWrite"));
-});
diff --git a/crates/agent-ui/src/components/chat/TaskProgressIndicator.tsx b/crates/agent-ui/src/components/chat/TaskProgressIndicator.tsx
index c9feb3ac7..416b4cbc6 100644
--- a/crates/agent-ui/src/components/chat/TaskProgressIndicator.tsx
+++ b/crates/agent-ui/src/components/chat/TaskProgressIndicator.tsx
@@ -8,7 +8,7 @@ import {
useRef,
useState,
} from "react";
-import type { TodoProgressSnapshot } from "../../lib/chat/taskProgress";
+import type { TaskProgressSnapshot } from "../../lib/chat/taskProgress";
import { cn } from "../../lib/shared/utils";
const POINTER_CLOSE_DELAY_MS = 140;
@@ -28,7 +28,7 @@ export function TaskProgressIndicator({
isConversationRunning,
labels,
}: {
- snapshot: TodoProgressSnapshot;
+ snapshot: TaskProgressSnapshot;
isConversationRunning: boolean;
labels: TaskProgressIndicatorLabels;
}) {
@@ -194,25 +194,24 @@ export function TaskProgressIndicator({