diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index d700fb3b1..6152bacd8 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -6,9 +6,8 @@ import type { } from "@liveagent/ui/components/chat/MentionComposer"; import { type NotifyItem, NotifyToast } from "@liveagent/ui/components/chat/NotifyToast"; import { SharedHistoryManagerModal } from "@liveagent/ui/components/chat/SharedHistoryManagerModal"; -import { TaskProgressIndicator } from "@liveagent/ui/components/chat/TaskProgressIndicator"; +import { TaskProgressBar } from "@liveagent/ui/components/chat/TaskProgressBar"; import { ToolApprovalBar } from "@liveagent/ui/components/chat/ToolApprovalBar"; -import { useSequencedTaskProgress } from "@liveagent/ui/components/chat/useSequencedTaskProgress"; import { WorkspaceResourceSettingsDrawer } from "@liveagent/ui/components/chat/WorkspaceResourceSettingsDrawer"; import type { GitCommitContextPayload, @@ -18,13 +17,10 @@ import { RightDockPanel } from "@liveagent/ui/components/project-tools/RightDock import { Button } from "@liveagent/ui/components/ui/button"; import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; import { ScrollArea } from "@liveagent/ui/components/ui/scroll-area"; -import { type Locale, LocaleContext, t as translate } from "@liveagent/ui/i18n/index"; +import { LocaleContext, t as translate } from "@liveagent/ui/i18n/index"; import { normalizeLogicalLineEndings } from "@liveagent/ui/lib/chat/composerText"; import { openChatFileLink } from "@liveagent/ui/lib/chat/openChatFileLink"; -import { - selectTodoProgressUpdates, - type TodoProgressUpdate, -} from "@liveagent/ui/lib/chat/taskProgress"; +import { selectLatestTaskProgress } from "@liveagent/ui/lib/chat/taskProgress"; import { readToolApprovalDeadlineAt, readToolApprovalPending, @@ -150,41 +146,6 @@ import type { SectionId } from "@/pages/settings/types"; const LOCAL_DRAFT_PREFIX = "__local_draft__:"; -function CurrentTaskProgress(props: { - updates: readonly TodoProgressUpdate[]; - isConversationRunning: boolean; - locale: Locale; -}) { - const { updates, isConversationRunning, locale } = props; - const snapshot = useSequencedTaskProgress(updates, isConversationRunning); - const labels = useMemo(() => { - if (!snapshot) return null; - return { - title: translate("chat.taskProgress.title", locale), - step: translate("chat.taskProgress.step", locale) - .replace("{current}", String(snapshot.currentStep)) - .replace("{total}", String(snapshot.totalCount)), - completedCount: `${snapshot.completedCount}/${snapshot.totalCount} ${translate( - "chat.taskProgress.completedCount", - locale, - )}`, - running: translate("chat.taskProgress.running", locale), - pending: translate("chat.taskProgress.pending", locale), - paused: translate("chat.taskProgress.paused", locale), - completed: translate("chat.taskProgress.completed", locale), - }; - }, [locale, snapshot]); - - if (!snapshot || !labels) return null; - return ( - - ); -} - function createLocalDraftConversationId() { return `${LOCAL_DRAFT_PREFIX}${createUuid()}`; } @@ -4469,8 +4430,8 @@ export default function GatewayApp() { return item?.title ?? ""; }, [selectedHistoryId, sidebarConversationsById]); const transcriptRows = displayedTranscript.rows; - const taskProgressUpdates = useMemo( - () => selectTodoProgressUpdates(transcriptRows), + const taskProgressSnapshot = useMemo( + () => selectLatestTaskProgress(transcriptRows), [transcriptRows], ); // 当前会话的待审批工具:遍历渲染中的 transcript,筛出带 __toolApprovalPending 标记 @@ -5173,11 +5134,10 @@ export default function GatewayApp() { onEditQueuedTurn={editQueuedTurn} onRemoveQueuedTurn={removeQueuedTurn} taskProgressBar={ - } approvalBar={approvalBar} diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index f2957e76c..c25a807ff 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -1,3 +1,5 @@ +import { TASK_TRANSLATIONS } from "@liveagent/ui/i18n/taskTranslations"; + /** * Simple i18n translation layer * Maps keys to localized strings for zh-CN and en-US @@ -11,6 +13,7 @@ export const SUPPORTED_LOCALES = ["zh-CN", "en-US"] as const satisfies readonly export const translations: Record> = { "zh-CN": { + ...TASK_TRANSLATIONS["zh-CN"], /* ── App / Global ── */ "app.errorBoundaryCopy": "复制错误信息", "app.errorBoundaryDesc": "界面渲染发生错误,正在进行的任务不受影响。请重新加载页面。", @@ -283,22 +286,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": "运行中", - "chat.taskProgress.pending": "待处理", - "chat.taskProgress.paused": "已暂停或中断", - "chat.taskProgress.completed": "全部完成", - "chat.taskProgress.completedCount": "已完成", "chat.tool.askUserTitle": "向你提问", "chat.askUser.preparing": "正在准备问题", "chat.askUser.waiting": "等待你的选择", @@ -1343,10 +1336,6 @@ 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.ask_user_question.name": "用户提问", "settings.builtinTool.ask_user_question.desc": "以选项卡片向你提问并等待选择", "settings.builtinTool.ask_user_question.detail": @@ -2237,6 +2226,7 @@ export const translations: Record> = { }, "en-US": { + ...TASK_TRANSLATIONS["en-US"], /* ── App / Global ── */ "app.errorBoundaryCopy": "Copy error details", "app.errorBoundaryDesc": @@ -2527,22 +2517,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", - "chat.taskProgress.pending": "Pending", - "chat.taskProgress.paused": "Paused or interrupted", - "chat.taskProgress.completed": "All completed", - "chat.taskProgress.completedCount": "completed", "chat.tool.askUserTitle": "Question for you", "chat.askUser.preparing": "Preparing questions", "chat.askUser.waiting": "Waiting for your choice", @@ -3626,10 +3606,6 @@ 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.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-gateway/web/src/lib/tools/builtinTypes.ts b/crates/agent-gateway/web/src/lib/tools/builtinTypes.ts index 1c3f8a3ed..9f86cc390 100644 --- a/crates/agent-gateway/web/src/lib/tools/builtinTypes.ts +++ b/crates/agent-gateway/web/src/lib/tools/builtinTypes.ts @@ -1,3 +1,4 @@ +import type { TaskListResultDetails } from "@liveagent/ui/contracts/task"; import type { SubagentBatchDetails, SubagentCardDetails, @@ -5,6 +6,13 @@ import type { } from "@liveagent/ui/lib/subagents/protocol"; import type { Tool, ToolCall, ToolResultMessage } from "../agentTypes"; +export type { + TaskItem, + TaskListResultDetails, + TaskListState, + TaskStatus, +} from "@liveagent/ui/contracts/task"; + export type BuiltinToolGroupId = "fs" | "shell" | "skill" | "system" | "mcp" | "subagent"; export type BuiltinToolDisplayCategory = @@ -345,17 +353,6 @@ export type GrepResultDetails = { files: GrepResultFileSummary[]; }; -export type TodoItem = { - content: string; - status: "pending" | "in_progress" | "completed"; - activeForm: string; -}; - -export type TodoWriteResultDetails = { - kind: "todo_write"; - todos: TodoItem[]; -}; - export type BuiltinToolResultDetails = | ReadTextResultDetails | ReadImageResultDetails @@ -374,5 +371,5 @@ export type BuiltinToolResultDetails = | ListResultDetails | GlobResultDetails | GrepResultDetails - | TodoWriteResultDetails + | TaskListResultDetails | Record; diff --git a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx index 4aace920f..c7bb749d1 100644 --- a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx +++ b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx @@ -56,10 +56,6 @@ export const AssistantBubble = memo(function AssistantBubble(props: { workdir, onOpenFileLink, } = props; - const isAborted = useMemo( - () => rounds.some((round) => round.meta?.stopReason === "aborted"), - [rounds], - ); // 回复末尾的已编辑文件卡:聚合整条回复所有 round 的 Write/Edit/Delete, // 只在回复结束(流停止)后出现;脱敏视图(分享页隐藏工具内容)不渲染。 const changedFiles = useMemo( @@ -87,7 +83,6 @@ export const AssistantBubble = memo(function AssistantBubble(props: { thinkingOpen={round.thinkingOpen} readOnly={readOnly} redactToolContent={redactToolContent} - isAborted={isAborted} workdir={workdir} onOpenFileLink={onOpenFileLink} /> diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx index acb649d01..36d9ff969 100644 --- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx +++ b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx @@ -9,7 +9,7 @@ import { ThinkingActivity } from "@liveagent/ui/components/chat/ThinkingActivity import { UsagePanel } from "@liveagent/ui/components/chat/UsagePanel"; import { Markdown } from "@liveagent/ui/components/Markdown"; import { useLocale } from "@liveagent/ui/i18n/index"; -import { isTodoWriteToolBlock } from "@liveagent/ui/lib/chat/taskProgress"; +import { isTaskToolBlock } from "@liveagent/ui/lib/chat/taskProgress"; import { memo, useMemo, useState } from "react"; import { ChevronRight, RefreshCw } from "../../../components/icons"; import type { ChatFileLink } from "../../../lib/chat/chatFileLinks"; @@ -90,7 +90,6 @@ export const RoundContent = memo(function RoundContent(props: { renderMode?: "streaming" | "static"; readOnly?: boolean; redactToolContent?: boolean; - isAborted?: boolean; workdir?: string; onOpenFileLink?: (link: ChatFileLink) => void; }) { @@ -108,13 +107,12 @@ export const RoundContent = memo(function RoundContent(props: { renderMode, readOnly = false, redactToolContent = false, - isAborted = false, workdir, onOpenFileLink, } = props; const groupedBlocks = useMemo(() => groupRoundBlocks(round.blocks), [round.blocks]); const visibleGroupedBlocks = useMemo( - () => groupedBlocks.filter((block) => !isTodoWriteToolBlock(block)), + () => groupedBlocks.filter((block) => !isTaskToolBlock(block)), [groupedBlocks], ); const hasContent = @@ -159,20 +157,7 @@ export const RoundContent = memo(function RoundContent(props: { if (!hasContent) return null; return ( -
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..18f234147 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 @@ -1,3 +1,4 @@ +import { isTaskToolName } from "@liveagent/ui/contracts/task"; import type { SubagentCardDetails, SubagentReportDetails, @@ -36,6 +37,9 @@ export function getToolMeta(name: string): { accent: string; category: string; } { + if (isTaskToolName(name)) { + return { Icon: ListChecks, accent: "var(--tool-list-accent)", category: "system" }; + } switch (name) { case "Bash": case "ManagedProcess": @@ -73,8 +77,6 @@ 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": - return { Icon: ListChecks, accent: "var(--tool-list-accent)", category: "system" }; case "AskUserQuestion": return { Icon: CircleHelp, accent: "var(--tool-list-accent)", category: "system" }; default: @@ -277,7 +279,7 @@ export function groupRoundBlocks(blocks: UiRound["blocks"]): GroupedRoundBlock[] flushPendingSearches(); if ( block.item.toolCall.name === "Image" || - block.item.toolCall.name === "TodoWrite" || + isTaskToolName(block.item.toolCall.name) || block.item.toolCall.name === "AskUserQuestion" || isAgentToolName(block.item.toolCall.name) ) { @@ -328,6 +330,9 @@ export function isBuiltinShareToolName(name: string) { if (trimmed.startsWith("mcp_")) { return true; } + if (isTaskToolName(trimmed)) { + return true; + } return [ "Agent", "AskUserQuestion", @@ -348,7 +353,6 @@ export function isBuiltinShareToolName(name: string) { "SkillsManager", "SSHManager", "SshManager", - "TodoWrite", "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..a5a0c8cea 100644 --- a/crates/agent-gateway/web/test/task-progress-indicator.test.mjs +++ b/crates/agent-gateway/web/test/task-progress-indicator.test.mjs @@ -7,6 +7,12 @@ import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; const rootDir = fileURLToPath(new URL("../", import.meta.url)); const iconsPath = fileURLToPath(new URL("../src/components/icons/index.ts", import.meta.url)); const utilsPath = fileURLToPath(new URL("../src/lib/shared/utils.ts", import.meta.url)); +const localeContextPath = fileURLToPath( + new URL("../../../agent-ui/src/i18n/LocaleContext.tsx", import.meta.url), +); +const taskProgressIndicatorPath = fileURLToPath( + new URL("../../../agent-ui/src/components/chat/TaskProgressIndicator.tsx", import.meta.url), +); const labels = { title: "Task progress", @@ -96,17 +102,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 +225,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 +254,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 +345,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 +365,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, @@ -328,3 +384,34 @@ test("web shows pending, paused, and completed states without auto-dismissing co assert.match(treeText(indicator.render({ snapshot: completed })), /All completed/); assert.match(treeText(indicator.render({ snapshot: completed })), /All completed/); }); + +test("web uses the shared localized task progress bar", () => { + const indicator = (props) => ({ type: "TaskProgressIndicator", props }); + const translations = { + "chat.taskProgress.title": "Task progress", + "chat.taskProgress.step": "Step {current} of {total}", + "chat.taskProgress.completedCount": "completed", + "chat.taskProgress.running": "Running", + "chat.taskProgress.pending": "Pending", + "chat.taskProgress.paused": "Paused", + "chat.taskProgress.completed": "All completed", + }; + const loader = createWebModuleLoader({ + rootDir, + mocks: { + [localeContextPath]: { + useLocale: () => ({ t: (key) => translations[key] ?? key }), + }, + [taskProgressIndicatorPath]: { TaskProgressIndicator: indicator }, + }, + }); + const { TaskProgressBar } = loader.loadModule( + "@liveagent/ui/components/chat/TaskProgressBar.tsx", + ); + const snapshot = createSnapshot(); + const tree = TaskProgressBar({ snapshot, isConversationRunning: true }); + + assert.equal(tree.type, indicator); + assert.deepEqual(tree.props.labels, labels); + assert.equal(TaskProgressBar({ snapshot: null, isConversationRunning: false }), null); +}); 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..93a830a30 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -1,3 +1,5 @@ +import { TASK_TRANSLATIONS } from "@liveagent/ui/i18n/taskTranslations"; + /** * Simple i18n translation layer * Maps keys to localized strings for zh-CN and en-US @@ -11,6 +13,7 @@ export const SUPPORTED_LOCALES = ["zh-CN", "en-US"] as const satisfies readonly export const translations: Record> = { "zh-CN": { + ...TASK_TRANSLATIONS["zh-CN"], /* ── App / Global ── */ "app.errorBoundaryCopy": "复制错误信息", "app.errorBoundaryDesc": "界面渲染发生错误,正在进行的任务不受影响。请重新加载页面。", @@ -304,22 +307,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": "运行中", - "chat.taskProgress.pending": "待处理", - "chat.taskProgress.paused": "已暂停或中断", - "chat.taskProgress.completed": "全部完成", - "chat.taskProgress.completedCount": "已完成", "chat.tool.askUserTitle": "向你提问", "chat.askUser.preparing": "正在准备问题", "chat.askUser.waiting": "等待你的选择", @@ -1407,10 +1400,6 @@ 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.ask_user_question.name": "用户提问", "settings.builtinTool.ask_user_question.desc": "以选项卡片向你提问并等待选择", "settings.builtinTool.ask_user_question.detail": @@ -2296,6 +2285,7 @@ export const translations: Record> = { }, "en-US": { + ...TASK_TRANSLATIONS["en-US"], /* ── App / Global ── */ "app.errorBoundaryCopy": "Copy error details", "app.errorBoundaryDesc": @@ -2610,22 +2600,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", - "chat.taskProgress.pending": "Pending", - "chat.taskProgress.paused": "Paused or interrupted", - "chat.taskProgress.completed": "All completed", - "chat.taskProgress.completedCount": "completed", "chat.tool.askUserTitle": "Question for you", "chat.askUser.preparing": "Preparing questions", "chat.askUser.waiting": "Waiting for your choice", @@ -3758,10 +3738,6 @@ 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.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..da4ecab14 100644 --- a/crates/agent-gui/src/lib/tools/builtinTypes.ts +++ b/crates/agent-gui/src/lib/tools/builtinTypes.ts @@ -1,5 +1,6 @@ import type { Tool, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai"; +import type { TaskListResultDetails } from "@liveagent/ui/contracts/task"; import type { SubagentBatchDetails, SubagentCardDetails, @@ -7,6 +8,13 @@ import type { } from "@liveagent/ui/lib/subagents/protocol"; import type { SubagentScheduler } from "../subagents/scheduler"; +export type { + TaskItem, + TaskListResultDetails, + TaskListState, + TaskStatus, +} from "@liveagent/ui/contracts/task"; + export type BuiltinToolGroupId = | "fs" | "shell" @@ -366,17 +374,6 @@ export type GrepResultDetails = { files: GrepResultFileSummary[]; }; -export type TodoItem = { - content: string; - status: "pending" | "in_progress" | "completed"; - activeForm: string; -}; - -export type TodoWriteResultDetails = { - kind: "todo_write"; - todos: TodoItem[]; -}; - export type BuiltinToolResultDetails = | ReadTextResultDetails | ReadImageResultDetails @@ -395,5 +392,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..335cc67c7 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -8,9 +8,8 @@ import { HistoryShareModal } from "@liveagent/ui/components/chat/HistoryShareMod import type { MentionComposerHandle } from "@liveagent/ui/components/chat/MentionComposer"; 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 { TaskProgressBar } from "@liveagent/ui/components/chat/TaskProgressBar"; 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, @@ -181,7 +179,6 @@ function CurrentTaskProgress(props: { isConversationRunning: boolean; }) { const { historyItems, liveTranscriptStore, isConversationRunning } = props; - const { t } = useLocale(); const getLiveRoundsSnapshot = useCallback( () => liveTranscriptStore.getSnapshot().liveRounds, [liveTranscriptStore], @@ -191,36 +188,11 @@ 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 { - title: t("chat.taskProgress.title"), - step: t("chat.taskProgress.step") - .replace("{current}", String(snapshot.currentStep)) - .replace("{total}", String(snapshot.totalCount)), - completedCount: `${snapshot.completedCount}/${snapshot.totalCount} ${t( - "chat.taskProgress.completedCount", - )}`, - running: t("chat.taskProgress.running"), - pending: t("chat.taskProgress.pending"), - paused: t("chat.taskProgress.paused"), - completed: t("chat.taskProgress.completed"), - }; - }, [snapshot, t]); - - if (!snapshot || !labels) return null; - return ( - - ); + return ; } export function ChatPage(props: ChatPageProps) { @@ -1068,7 +1040,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..823cb0ad8 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 @@ -1,4 +1,5 @@ import type { ToolResultMessage } from "@earendil-works/pi-ai"; +import { isTaskToolName } from "@liveagent/ui/contracts/task"; import type { SubagentCardDetails, SubagentReportDetails, @@ -36,6 +37,9 @@ export function getToolMeta(name: string): { accent: string; category: string; } { + if (isTaskToolName(name)) { + return { Icon: ListChecks, accent: "var(--tool-list-accent)", category: "system" }; + } switch (name) { case "Bash": case "ManagedProcess": @@ -73,8 +77,6 @@ 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": - return { Icon: ListChecks, accent: "var(--tool-list-accent)", category: "system" }; case "AskUserQuestion": return { Icon: CircleHelp, accent: "var(--tool-list-accent)", category: "system" }; default: @@ -306,7 +308,7 @@ export function groupRoundBlocks(blocks: UiRound["blocks"]): GroupedRoundBlock[] flushPendingSearches(); if ( block.item.toolCall.name === "Image" || - block.item.toolCall.name === "TodoWrite" || + isTaskToolName(block.item.toolCall.name) || 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..dc2a96384 100644 --- a/crates/agent-gui/test/chat/task-progress-indicator.test.mjs +++ b/crates/agent-gui/test/chat/task-progress-indicator.test.mjs @@ -6,6 +6,15 @@ import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; const iconsPath = fileURLToPath(new URL("../../src/components/icons/index.ts", import.meta.url)); const utilsPath = fileURLToPath(new URL("../../src/lib/shared/utils.ts", import.meta.url)); +const localeContextPath = fileURLToPath( + new URL("../../../agent-ui/src/i18n/LocaleContext.tsx", import.meta.url), +); +const taskProgressIndicatorPath = fileURLToPath( + new URL( + "../../../agent-ui/src/components/chat/TaskProgressIndicator.tsx", + import.meta.url, + ), +); const labels = { title: "Task progress", @@ -94,17 +103,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 +226,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 +255,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 +346,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 +366,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, @@ -326,3 +385,33 @@ test("shows pending, paused, and completed states without auto-dismissing comple assert.match(treeText(indicator.render({ snapshot: completed })), /All completed/); assert.match(treeText(indicator.render({ snapshot: completed })), /All completed/); }); + +test("shared task progress bar localizes labels and handles an empty snapshot", () => { + const indicator = (props) => ({ type: "TaskProgressIndicator", props }); + const translations = { + "chat.taskProgress.title": "Task progress", + "chat.taskProgress.step": "Step {current} of {total}", + "chat.taskProgress.completedCount": "completed", + "chat.taskProgress.running": "Running", + "chat.taskProgress.pending": "Pending", + "chat.taskProgress.paused": "Paused", + "chat.taskProgress.completed": "All completed", + }; + const loader = createTsModuleLoader({ + mocks: { + [localeContextPath]: { + useLocale: () => ({ t: (key) => translations[key] ?? key }), + }, + [taskProgressIndicatorPath]: { TaskProgressIndicator: indicator }, + }, + }); + const { TaskProgressBar } = loader.loadModule( + "@liveagent/ui/components/chat/TaskProgressBar.tsx", + ); + const snapshot = createSnapshot(); + const tree = TaskProgressBar({ snapshot, isConversationRunning: true }); + + assert.equal(tree.type, indicator); + assert.deepEqual(tree.props.labels, labels); + assert.equal(TaskProgressBar({ snapshot: null, isConversationRunning: false }), null); +}); 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/README.md b/crates/agent-ui/README.md index 57310e775..fdc1d50d4 100644 --- a/crates/agent-ui/README.md +++ b/crates/agent-ui/README.md @@ -8,6 +8,7 @@ - `src/pages/`:设置、Skills、MCP 等完整公共页面。 - `src/components/`:聊天侧栏、输入框、项目工具、编辑器等公共 UI。 - `src/contracts/`:定义共享 UI 的扩展注册表等公共契约。 +- `src/i18n/`:定义两端共同使用的翻译片段与本地化上下文。 GUI 与 WebUI 应用只负责: diff --git a/crates/agent-ui/src/components/chat/TaskProgressBar.tsx b/crates/agent-ui/src/components/chat/TaskProgressBar.tsx new file mode 100644 index 000000000..78ddf1345 --- /dev/null +++ b/crates/agent-ui/src/components/chat/TaskProgressBar.tsx @@ -0,0 +1,40 @@ +import { useLocale } from "../../i18n/LocaleContext"; +import type { TaskProgressSnapshot } from "../../lib/chat/taskProgress"; +import { TaskProgressIndicator, type TaskProgressIndicatorLabels } from "./TaskProgressIndicator"; + +export function createTaskProgressIndicatorLabels( + snapshot: TaskProgressSnapshot, + translate: (key: string) => string, +): TaskProgressIndicatorLabels { + return { + title: translate("chat.taskProgress.title"), + step: translate("chat.taskProgress.step") + .replace("{current}", String(snapshot.currentStep)) + .replace("{total}", String(snapshot.totalCount)), + completedCount: `${snapshot.completedCount}/${snapshot.totalCount} ${translate( + "chat.taskProgress.completedCount", + )}`, + running: translate("chat.taskProgress.running"), + pending: translate("chat.taskProgress.pending"), + paused: translate("chat.taskProgress.paused"), + completed: translate("chat.taskProgress.completed"), + }; +} + +export function TaskProgressBar({ + snapshot, + isConversationRunning, +}: { + snapshot: TaskProgressSnapshot | null; + isConversationRunning: boolean; +}) { + const { t } = useLocale(); + if (!snapshot) return null; + return ( + + ); +} 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({
    - {snapshot.todos.map((todo, index) => { + {snapshot.tasks.map((task) => { return (
  • - {todo.status === "completed" ? ( + {task.status === "completed" ? ( - ) : todo.status === "in_progress" ? ( + ) : task.status === "in_progress" ? ( - {todo.content} + {task.subject}
  • ); diff --git a/crates/agent-ui/src/components/chat/TodoListView.tsx b/crates/agent-ui/src/components/chat/TodoListView.tsx deleted file mode 100644 index 1d8bfca0f..000000000 --- a/crates/agent-ui/src/components/chat/TodoListView.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { CheckCircle2, Circle, Loader2 } from "@liveagent/app/components/icons"; -import type { TodoItem } from "@liveagent/app/lib/tools/builtinTypes"; -import { useLocale } from "@liveagent/ui/i18n/index"; - -/** - * Defensive shape filter for rendering todos straight from streaming tool-call - * arguments: partially parsed items (missing fields, wrong types) are dropped - * instead of crashing the checklist. - */ -export function sanitizeTodoItems(value: unknown): TodoItem[] { - if (!Array.isArray(value)) return []; - return value.filter((item): item is TodoItem => { - if (!item || typeof item !== "object") return false; - const candidate = item as Record; - return ( - typeof candidate.content === "string" && - (candidate.status === "pending" || - candidate.status === "in_progress" || - candidate.status === "completed") && - typeof candidate.activeForm === "string" - ); - }); -} - -function TodoRow(props: { todo: TodoItem }) { - const { todo } = props; - const label = todo.status === "in_progress" ? todo.activeForm : todo.content; - - return ( -
  • - - {todo.status === "completed" ? ( - - ) : todo.status === "in_progress" ? ( - - ) : ( - - )} - - - {label} - -
  • - ); -} - -export function TodoListView(props: { todos: TodoItem[] }) { - const { todos } = props; - const { t } = useLocale(); - - if (!Array.isArray(todos) || todos.length === 0) { - return
    {t("chat.tool.todoEmpty")}
    ; - } - - return ( -
      - {todos.map((todo, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: todos are a full-replace snapshot with no stable id - - ))} -
    - ); -} diff --git a/crates/agent-ui/src/components/chat/useSequencedTaskProgress.ts b/crates/agent-ui/src/components/chat/useSequencedTaskProgress.ts deleted file mode 100644 index cebf7ca33..000000000 --- a/crates/agent-ui/src/components/chat/useSequencedTaskProgress.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { - applyTodoProgressUpdate, - foldTodoProgressUpdates, - type TodoProgressPlan, - type TodoProgressSnapshot, - type TodoProgressUpdate, - todoProgressSnapshotSignature, -} from "../../lib/chat/taskProgress"; - -export const TASK_PROGRESS_SEQUENCE_STEP_MS = 180; -export const TASK_PROGRESS_ARGUMENT_STABLE_MS = 240; - -type QueuedSnapshot = { - snapshot: TodoProgressSnapshot | null; - signature: string; -}; - -type PendingArgumentUpdate = { - update: TodoProgressUpdate; -}; - -function updateSignature(update: TodoProgressUpdate): string { - const phase = update.settled === false ? "arguments" : "settled"; - const snapshot = - update.snapshot === undefined ? "invalid" : todoProgressSnapshotSignature(update.snapshot); - return `${phase}:${snapshot}`; -} - -function foldVisibleUpdates( - updates: readonly TodoProgressUpdate[], - isConversationRunning: boolean, -): TodoProgressPlan { - return foldTodoProgressUpdates( - isConversationRunning ? updates.filter((update) => update.settled !== false) : updates, - ); -} - -export function useSequencedTaskProgress( - updates: readonly TodoProgressUpdate[], - isConversationRunning = true, -): TodoProgressSnapshot | null { - const latestUpdate = updates[updates.length - 1]; - const latestUpdateClears = latestUpdate?.settled !== false && latestUpdate?.snapshot === null; - const initialPlan = foldVisibleUpdates(updates, isConversationRunning); - const initialSnapshot = initialPlan.snapshot; - const [displayedSnapshot, setDisplayedSnapshot] = useState( - initialSnapshot, - ); - const initializedRef = useRef(false); - const planRef = useRef(initialPlan); - const seenSignaturesRef = useRef(new Map()); - const queueRef = useRef([]); - const timerRef = useRef(null); - const argumentTimerRef = useRef(null); - const pendingArgumentRef = useRef(null); - const displayedSignatureRef = useRef(todoProgressSnapshotSignature(initialSnapshot)); - const drainRef = useRef<() => void>(() => undefined); - const enqueueSnapshotRef = useRef<(snapshot: TodoProgressSnapshot | null) => void>( - () => undefined, - ); - const clearArgumentFallbackRef = useRef<(key?: string) => void>(() => undefined); - const scheduleArgumentFallbackRef = useRef<(update: TodoProgressUpdate) => void>(() => undefined); - - drainRef.current = () => { - const next = queueRef.current.shift(); - if (!next) { - timerRef.current = null; - return; - } - displayedSignatureRef.current = next.signature; - setDisplayedSnapshot(next.snapshot); - if (queueRef.current.length > 0) { - timerRef.current = window.setTimeout(() => { - timerRef.current = null; - drainRef.current(); - }, TASK_PROGRESS_SEQUENCE_STEP_MS); - } else { - timerRef.current = null; - } - }; - - enqueueSnapshotRef.current = (snapshot: TodoProgressSnapshot | null) => { - const signature = todoProgressSnapshotSignature(snapshot); - const tailSignature = - queueRef.current[queueRef.current.length - 1]?.signature ?? displayedSignatureRef.current; - if (signature === tailSignature) return; - queueRef.current.push({ snapshot, signature }); - if (timerRef.current === null) drainRef.current(); - }; - - clearArgumentFallbackRef.current = (key?: string) => { - if (key && pendingArgumentRef.current?.update.key !== key) return; - if (argumentTimerRef.current !== null) window.clearTimeout(argumentTimerRef.current); - argumentTimerRef.current = null; - pendingArgumentRef.current = null; - }; - - scheduleArgumentFallbackRef.current = (update: TodoProgressUpdate) => { - clearArgumentFallbackRef.current(); - if (update.snapshot === undefined) return; - pendingArgumentRef.current = { update }; - argumentTimerRef.current = window.setTimeout(() => { - argumentTimerRef.current = null; - const pending = pendingArgumentRef.current; - pendingArgumentRef.current = null; - if (!pending) return; - const nextPlan = applyTodoProgressUpdate(planRef.current, pending.update); - planRef.current = nextPlan; - enqueueSnapshotRef.current(nextPlan.snapshot); - }, TASK_PROGRESS_ARGUMENT_STABLE_MS); - }; - - useEffect(() => { - const seen = seenSignaturesRef.current; - if (!initializedRef.current) { - for (const update of updates) { - seen.set(update.key, updateSignature(update)); - } - planRef.current = foldVisibleUpdates(updates, isConversationRunning); - initializedRef.current = true; - const provisional = updates[updates.length - 1]; - if (isConversationRunning && provisional?.settled === false) { - scheduleArgumentFallbackRef.current(provisional); - } - return; - } - if (updates.length === 0) { - // Live rounds may disappear one render before their persisted history - // replacement arrives. Keep the last visible state through that handoff. - return; - } - - const hadSeenUpdates = seen.size > 0; - if (!hadSeenUpdates && !isConversationRunning) { - // An idle empty -> populated transition is history hydration, not new - // live progress. Adopt the restored conversation without replaying it. - const hydratedPlan = foldTodoProgressUpdates(updates); - for (const update of updates) { - seen.set(update.key, updateSignature(update)); - } - planRef.current = hydratedPlan; - displayedSignatureRef.current = todoProgressSnapshotSignature(hydratedPlan.snapshot); - setDisplayedSnapshot(hydratedPlan.snapshot); - return; - } - let lastSeenIndex = -1; - for (let index = 0; index < updates.length; index += 1) { - if (seen.has(updates[index]?.key ?? "")) lastSeenIndex = index; - } - - if (hadSeenUpdates && lastSeenIndex < 0) { - // A non-overlapping history replacement is not a live append. Adopt its - // latest state without replaying an entire restored conversation. - if (timerRef.current !== null) window.clearTimeout(timerRef.current); - timerRef.current = null; - queueRef.current = []; - clearArgumentFallbackRef.current(); - for (const update of updates) { - seen.set(update.key, updateSignature(update)); - } - const replacementPlan = foldVisibleUpdates(updates, isConversationRunning); - planRef.current = replacementPlan; - displayedSignatureRef.current = todoProgressSnapshotSignature(replacementPlan.snapshot); - setDisplayedSnapshot(replacementPlan.snapshot); - const provisional = updates[updates.length - 1]; - if (isConversationRunning && provisional?.settled === false) { - scheduleArgumentFallbackRef.current(provisional); - } - return; - } - - const candidates: QueuedSnapshot[] = []; - const provisionalUpdates: PendingArgumentUpdate[] = []; - let nextPlan = planRef.current; - for (let index = 0; index < updates.length; index += 1) { - const update = updates[index]; - if (!update) continue; - const signature = updateSignature(update); - const previousSignature = seen.get(update.key); - if ( - (previousSignature !== undefined && previousSignature !== signature) || - (previousSignature === undefined && (!hadSeenUpdates || index > lastSeenIndex)) - ) { - if (update.settled === false) { - provisionalUpdates.push({ update }); - } else { - clearArgumentFallbackRef.current(update.key); - nextPlan = applyTodoProgressUpdate(nextPlan, update); - if (update.snapshot !== null && update.snapshot !== undefined) { - candidates.push({ - snapshot: nextPlan.snapshot, - signature: todoProgressSnapshotSignature(nextPlan.snapshot), - }); - } - } - } - seen.set(update.key, signature); - } - planRef.current = nextPlan; - - if (latestUpdateClears) { - if (timerRef.current !== null) window.clearTimeout(timerRef.current); - timerRef.current = null; - queueRef.current = []; - clearArgumentFallbackRef.current(); - displayedSignatureRef.current = todoProgressSnapshotSignature(nextPlan.snapshot); - setDisplayedSnapshot(nextPlan.snapshot); - return; - } - - let tailSignature = - queueRef.current[queueRef.current.length - 1]?.signature ?? displayedSignatureRef.current; - for (const candidate of candidates) { - if (candidate.signature === tailSignature) continue; - queueRef.current.push(candidate); - tailSignature = candidate.signature; - } - if (timerRef.current === null && queueRef.current.length > 0) drainRef.current(); - for (const provisional of provisionalUpdates) { - scheduleArgumentFallbackRef.current(provisional.update); - } - }, [isConversationRunning, latestUpdateClears, updates]); - - useEffect( - () => () => { - if (timerRef.current !== null) window.clearTimeout(timerRef.current); - timerRef.current = null; - queueRef.current = []; - if (argumentTimerRef.current !== null) window.clearTimeout(argumentTimerRef.current); - argumentTimerRef.current = null; - pendingArgumentRef.current = null; - }, - [], - ); - - return latestUpdateClears ? null : displayedSnapshot; -} diff --git a/crates/agent-ui/src/contracts/task.ts b/crates/agent-ui/src/contracts/task.ts new file mode 100644 index 000000000..7e8ab180b --- /dev/null +++ b/crates/agent-ui/src/contracts/task.ts @@ -0,0 +1,34 @@ +export const TASK_TOOL_NAMES = ["TaskCreate", "TaskUpdate", "TaskList"] as const; + +export type TaskToolName = (typeof TASK_TOOL_NAMES)[number]; +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 TaskListResultDetails = { + kind: "task_list"; + action: "created" | "updated" | "listed"; + runId: string; + revision: number; + tasks: TaskItem[]; + taskId?: string; +}; + +const TASK_TOOL_NAME_SET = new Set(TASK_TOOL_NAMES); + +export function isTaskToolName(value: unknown): value is TaskToolName { + return typeof value === "string" && TASK_TOOL_NAME_SET.has(value); +} diff --git a/crates/agent-ui/src/i18n/taskTranslations.ts b/crates/agent-ui/src/i18n/taskTranslations.ts new file mode 100644 index 000000000..cd905549f --- /dev/null +++ b/crates/agent-ui/src/i18n/taskTranslations.ts @@ -0,0 +1,43 @@ +export const TASK_TRANSLATIONS = { + "zh-CN": { + "chat.taskProgress.title": "任务进度", + "chat.taskProgress.step": "第 {current} / {total} 步", + "chat.taskProgress.running": "运行中", + "chat.taskProgress.pending": "待处理", + "chat.taskProgress.paused": "已暂停或中断", + "chat.taskProgress.completed": "全部完成", + "chat.taskProgress.completedCount": "已完成", + "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;仅在对话场景注册。", + }, + "en-US": { + "chat.taskProgress.title": "Task progress", + "chat.taskProgress.step": "Step {current} of {total}", + "chat.taskProgress.running": "Running", + "chat.taskProgress.pending": "Pending", + "chat.taskProgress.paused": "Paused or interrupted", + "chat.taskProgress.completed": "All completed", + "chat.taskProgress.completedCount": "completed", + "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.", + }, +} as const satisfies Record<"zh-CN" | "en-US", Record>; diff --git a/crates/agent-ui/src/lib/chat/taskProgress.ts b/crates/agent-ui/src/lib/chat/taskProgress.ts index a7805a390..866b0f8e1 100644 --- a/crates/agent-ui/src/lib/chat/taskProgress.ts +++ b/crates/agent-ui/src/lib/chat/taskProgress.ts @@ -1,29 +1,19 @@ -import type { TodoItem } from "@liveagent/app/lib/tools/builtinTypes"; +import { isTaskToolName, type TaskItem } from "../../contracts/task"; -export type TodoProgressState = "pending" | "in_progress" | "completed"; +export type TaskProgressState = "pending" | "in_progress" | "completed"; -export type TodoProgressSnapshot = { - todos: TodoItem[]; +export type TaskProgressSnapshot = { + runId: string; + revision: number; + tasks: TaskItem[]; completedCount: number; totalCount: number; currentStep: number; - state: TodoProgressState; -}; - -export type TodoProgressUpdate = { - key: string; - snapshot: TodoProgressSnapshot | null | undefined; - /** False while the value only comes from incrementally streamed arguments. */ - settled?: boolean; -}; - -export type TodoProgressPlan = { - anchorKey: string | null; - snapshot: TodoProgressSnapshot | null; + state: TaskProgressState; }; type RecordLike = Record; -type TodoWriteToolBlock = RecordLike & { +type TaskToolBlock = RecordLike & { item: RecordLike & { toolCall: RecordLike }; }; @@ -31,233 +21,114 @@ function isRecord(value: unknown): value is RecordLike { return Boolean(value) && typeof value === "object"; } -export function isTodoWriteToolBlock(block: unknown): block is TodoWriteToolBlock { +export function isTaskToolBlock(block: unknown): block is TaskToolBlock { if (!isRecord(block) || block.kind !== "tool" || !isRecord(block.item)) return false; - return isRecord(block.item.toolCall) && block.item.toolCall.name === "TodoWrite"; + const toolCall = block.item.toolCall; + return isRecord(toolCall) && isTaskToolName(toolCall.name); } -export function readCompleteTodoList(value: unknown): TodoItem[] | null { +function readTaskItems(value: unknown): TaskItem[] | null { if (!Array.isArray(value)) return null; - const todos: TodoItem[] = []; + const tasks: TaskItem[] = []; + const ids = new Set(); let inProgressCount = 0; - for (const item of value) { - if (!isRecord(item)) return null; - if (typeof item.content !== "string" || !item.content.trim()) return null; - if (item.status !== "pending" && item.status !== "in_progress" && item.status !== "completed") { + for (const valueItem of value) { + if (!isRecord(valueItem)) return null; + const { id, subject, description, activeForm, status } = valueItem; + if ( + typeof id !== "string" || + !id.trim() || + ids.has(id) || + typeof subject !== "string" || + !subject.trim() || + typeof description !== "string" || + !description.trim() || + typeof activeForm !== "string" || + !activeForm.trim() || + (status !== "pending" && status !== "in_progress" && status !== "completed") + ) { return null; } - if (typeof item.activeForm !== "string" || !item.activeForm.trim()) return null; - if (item.status === "in_progress" && ++inProgressCount > 1) return null; - todos.push({ content: item.content, status: item.status, activeForm: item.activeForm }); + if (status === "in_progress" && ++inProgressCount > 1) return null; + ids.add(id); + tasks.push({ id, subject, description, activeForm, status }); } - return todos; + return tasks; } -type TodoWriteBlockRead = { - todos: TodoItem[] | undefined; - settled: boolean; -}; - -function readTodoWriteBlock(block: unknown): TodoWriteBlockRead | undefined { - if (!isTodoWriteToolBlock(block)) return undefined; - const toolCall = block.item.toolCall; - const toolResult = isRecord(block.item.toolResult) ? block.item.toolResult : null; - if (toolResult) { - if (toolResult.isError === true) return { todos: undefined, settled: true }; - if (!isRecord(toolResult.details) || toolResult.details.kind !== "todo_write") { - return { todos: undefined, settled: true }; - } - return { - todos: readCompleteTodoList(toolResult.details.todos) ?? undefined, - settled: true, - }; +function readTaskSnapshot(block: unknown): TaskProgressSnapshot | null | undefined { + if (!isTaskToolBlock(block) || !isRecord(block.item.toolResult)) return undefined; + const result = block.item.toolResult; + if (result.isError === true || !isRecord(result.details)) return undefined; + const details = result.details; + if ( + details.kind !== "task_list" || + typeof details.runId !== "string" || + !details.runId.trim() || + !Number.isSafeInteger(details.revision) || + (details.revision as number) < 0 + ) { + return undefined; } - return { - todos: isRecord(toolCall.arguments) - ? (readCompleteTodoList(toolCall.arguments.todos) ?? undefined) - : undefined, - settled: false, - }; + const tasks = readTaskItems(details.tasks); + return tasks + ? createTaskProgressSnapshot(details.runId, details.revision as number, tasks) + : undefined; } -export function createTodoProgressSnapshot(todos: TodoItem[]): TodoProgressSnapshot | null { - if (todos.length === 0) return null; - 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"); - const state: TodoProgressState = - completedCount === todos.length +export function createTaskProgressSnapshot( + runId: string, + revision: number, + tasks: TaskItem[], +): TaskProgressSnapshot | null { + if (tasks.length === 0) return null; + const completedCount = tasks.filter((task) => task.status === "completed").length; + const inProgressIndex = tasks.findIndex((task) => task.status === "in_progress"); + const pendingIndex = tasks.findIndex((task) => task.status === "pending"); + const state: TaskProgressState = + completedCount === tasks.length ? "completed" : inProgressIndex >= 0 ? "in_progress" : "pending"; - const currentStep = - inProgressIndex >= 0 - ? inProgressIndex + 1 - : pendingIndex >= 0 - ? pendingIndex + 1 - : todos.length; return { - todos, + runId, + revision, + tasks, completedCount, - totalCount: todos.length, - currentStep, + totalCount: tasks.length, + currentStep: + inProgressIndex >= 0 + ? inProgressIndex + 1 + : pendingIndex >= 0 + ? pendingIndex + 1 + : tasks.length, state, }; } -export function todoProgressSnapshotSignature(snapshot: TodoProgressSnapshot | null): string { - return JSON.stringify(snapshot?.todos ?? null); -} - -function cloneTodoProgressSnapshot(snapshot: TodoProgressSnapshot): TodoProgressSnapshot { - return createTodoProgressSnapshot( - snapshot.todos.map((todo) => ({ ...todo })), - ) as TodoProgressSnapshot; -} - -export function mergeTodoProgressSnapshots( - current: TodoProgressSnapshot, - incoming: TodoProgressSnapshot, -): TodoProgressSnapshot { - const nextTodos = current.todos.map((todo) => ({ ...todo })); - const usedIndexes = new Set(); - const matchedIndexes = incoming.todos.map((incomingTodo, incomingIndex) => { - let matchedIndex = current.todos.findIndex( - (todo, index) => !usedIndexes.has(index) && todo.content === incomingTodo.content, - ); - if ( - matchedIndex < 0 && - incoming.todos.length === current.todos.length && - !usedIndexes.has(incomingIndex) - ) { - matchedIndex = incomingIndex; - } - if (matchedIndex >= 0) usedIndexes.add(matchedIndex); - return matchedIndex; - }); - - const incomingActiveIndex = incoming.todos.findIndex((todo) => todo.status === "in_progress"); - const nextActiveIndex = - incomingActiveIndex >= 0 ? (matchedIndexes[incomingActiveIndex] ?? -1) : -1; - if (nextActiveIndex >= 0) { - for (let index = 0; index < nextTodos.length; index += 1) { - const todo = nextTodos[index]; - if (index !== nextActiveIndex && todo?.status === "in_progress") { - nextTodos[index] = { ...todo, status: "pending" }; - } - } - } - - for (let incomingIndex = 0; incomingIndex < incoming.todos.length; incomingIndex += 1) { - const matchedIndex = matchedIndexes[incomingIndex] ?? -1; - const existingTodo = nextTodos[matchedIndex]; - const incomingTodo = incoming.todos[incomingIndex]; - if (matchedIndex < 0 || !existingTodo || !incomingTodo) continue; - nextTodos[matchedIndex] = { ...existingTodo, status: incomingTodo.status }; - } - - return createTodoProgressSnapshot(nextTodos) as TodoProgressSnapshot; -} - -export function applyTodoProgressUpdate( - plan: TodoProgressPlan, - update: TodoProgressUpdate, -): TodoProgressPlan { - if (update.snapshot === undefined) return plan; - if (update.snapshot === null) return { anchorKey: null, snapshot: null }; - if (plan.snapshot === null) { - return { anchorKey: update.key, snapshot: cloneTodoProgressSnapshot(update.snapshot) }; - } - if (plan.anchorKey === update.key) { - return { anchorKey: plan.anchorKey, snapshot: cloneTodoProgressSnapshot(update.snapshot) }; - } - return { - anchorKey: plan.anchorKey, - snapshot: mergeTodoProgressSnapshots(plan.snapshot, update.snapshot), - }; -} - -export function foldTodoProgressUpdates(updates: readonly TodoProgressUpdate[]): TodoProgressPlan { - let plan: TodoProgressPlan = { anchorKey: null, snapshot: null }; - for (const update of updates) plan = applyTodoProgressUpdate(plan, update); - return plan; -} - -export function selectTodoProgressUpdates( +export function selectLatestTaskProgress( rows: readonly unknown[], liveRounds: readonly unknown[] = [], -): TodoProgressUpdate[] { - const updates: TodoProgressUpdate[] = []; - const updateIndexByKey = new Map(); - const ignoredTodoKeys = new Set(); - const visitRounds = (rounds: readonly unknown[], scope: string) => { - for (let roundIndex = 0; roundIndex < rounds.length; roundIndex += 1) { - const round = rounds[roundIndex]; +): TaskProgressSnapshot | null { + let latest: TaskProgressSnapshot | null = null; + const visitRounds = (rounds: readonly unknown[]) => { + for (const round of rounds) { if (!isRecord(round) || !Array.isArray(round.blocks)) continue; - for (let blockIndex = 0; blockIndex < round.blocks.length; blockIndex += 1) { - const block = round.blocks[blockIndex]; - const read = readTodoWriteBlock(block); - if (!read || !isTodoWriteToolBlock(block)) continue; - const callId = block.item.toolCall.id; - const key = - typeof callId === "string" && callId.trim() - ? callId.trim() - : `${scope}:${roundIndex}:${blockIndex}`; - if (ignoredTodoKeys.has(key)) continue; - const update: TodoProgressUpdate = { - key, - snapshot: read.todos === undefined ? undefined : createTodoProgressSnapshot(read.todos), - settled: read.settled, - }; - const existingIndex = updateIndexByKey.get(key); - if (existingIndex === undefined) { - updateIndexByKey.set(key, updates.length); - updates.push(update); - } else { - updates[existingIndex] = update; - } + for (const block of round.blocks) { + const snapshot = readTaskSnapshot(block); + if (snapshot !== undefined) latest = snapshot; } } }; - for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { - const row = rows[rowIndex]; + for (const row of rows) { if (!isRecord(row)) continue; if (row.kind === "user") { - for (const update of updates) { - if (update.snapshot !== null) ignoredTodoKeys.add(update.key); - } - updates.length = 0; - updateIndexByKey.clear(); - const item = isRecord(row.item) ? row.item : null; - const identity = - typeof row.key === "string" && row.key.trim() - ? row.key.trim() - : typeof row.id === "string" && row.id.trim() - ? row.id.trim() - : item && typeof item.id === "string" && item.id.trim() - ? item.id.trim() - : String(rowIndex); - const boundaryKey = `user-turn:${identity}`; - updates.push({ key: boundaryKey, snapshot: null, settled: true }); - updateIndexByKey.set(boundaryKey, 0); - continue; + latest = null; + } else if (row.kind === "assistant" && Array.isArray(row.rounds)) { + visitRounds(row.rounds); } - if (row.kind !== "assistant" || !Array.isArray(row.rounds)) continue; - visitRounds(row.rounds, `history:${rowIndex}`); - } - visitRounds(liveRounds, "live"); - return updates; -} - -export function selectLatestTodoProgress( - rows: readonly unknown[], - liveRounds: readonly unknown[] = [], -): TodoProgressSnapshot | null { - const updates = selectTodoProgressUpdates(rows, liveRounds); - for (let index = updates.length - 1; index >= 0; index -= 1) { - const snapshot = updates[index]?.snapshot; - if (snapshot !== undefined) return snapshot; } - return null; + visitRounds(liveRounds); + return latest; } diff --git a/crates/agent-ui/src/lib/tools/builtinToolCatalog.ts b/crates/agent-ui/src/lib/tools/builtinToolCatalog.ts index f62d0d6bb..e985b72a7 100644 --- a/crates/agent-ui/src/lib/tools/builtinToolCatalog.ts +++ b/crates/agent-ui/src/lib/tools/builtinToolCatalog.ts @@ -197,14 +197,32 @@ export const BUILTIN_TOOL_CATALOG: readonly BuiltinToolCatalogEntry[] = [ conditional: true, }, { - id: "todo_write", - toolName: "TodoWrite", + id: "task_create", + toolName: "TaskCreate", icon: "checklist", categoryId: "intelligence", isReadOnly: false, runtimeScopes: CHAT_ONLY, conditional: true, }, + { + id: "task_update", + toolName: "TaskUpdate", + icon: "checklist", + categoryId: "intelligence", + isReadOnly: false, + runtimeScopes: CHAT_ONLY, + conditional: true, + }, + { + id: "task_list", + toolName: "TaskList", + icon: "checklist", + categoryId: "intelligence", + isReadOnly: true, + runtimeScopes: CHAT_ONLY, + conditional: true, + }, { id: "ask_user_question", toolName: "AskUserQuestion", diff --git a/docs/features/tools.md b/docs/features/tools.md index ffc3581c8..c9be90c84 100644 --- a/docs/features/tools.md +++ b/docs/features/tools.md @@ -24,7 +24,7 @@ | Dynamic MCP tools | `mcpTools.ts` | 将已启用 MCP server 的 tool 暴露为 `mcp__`。 | | Custom system tools | `customSystemTools.ts` | HTTP test 等系统工具,由 Settings 中 selectedSystemTools 控制。 | | MemoryManager | `memoryTools.ts` | list/read/search/write/update/delete/accept,支持 global/project/daily 语义。 | -| TodoWrite | `todoTools.ts` | 会话内任务清单全量替换写入,仅 `runtimeScope=chat` 可用;状态存于内存(按 conversationId),不落盘、不进子代理注册表。 | +| Task tools | `taskTools.ts`、`taskState.ts` | `TaskCreate`/`TaskUpdate`/`TaskList` 按稳定数字 ID 增量维护当前 Run 的权威任务状态;状态随 `context_meta_json` 持久化并跨压缩 checkpoint 保留,仅 `runtimeScope=chat` 可用且不进入子代理注册表。 | | Subagent | `src/lib/subagents/*`(适配层 `agentTool.ts`、`sendMessageTool.ts`) | `Agent`/`SendMessage` 内置工具:委托持久化子代理、隔离 worktree、Message Bus。 | ## 执行边界 diff --git a/docs/worklog/live-transcript-jitter.md b/docs/worklog/live-transcript-jitter.md index e8c53bded..204d99df7 100644 --- a/docs/worklog/live-transcript-jitter.md +++ b/docs/worklog/live-transcript-jitter.md @@ -43,9 +43,9 @@ Keep the active assistant turn structurally stable while thinking, tools, tool r - Final visual feedback removed Streamdown's trailing live-text caret in both clients. The caret could render as a standalone white bar and reserve an otherwise empty line between a completed text block and the next tool; activity progress is already communicated by the stable status tail, so the duplicate cue is no longer emitted. - Status width is now bounded through the complete flex chain: the desktop activity row occupies the transcript width, status wrappers allow shrinking and clip overflow, and the status text itself owns the ellipsis. WebUI applies the same footer constraint, so long tool summaries cannot widen either transcript. -## TodoWrite compatibility +## Task tool compatibility -The task-progress PR worktree is based on a different stack and changes GUI `rowModel.ts` to hide all `TodoWrite` blocks. This task remains independent of that PR. A read-only `git apply --check` of this task's relevant projection patches against `codex/feat-task-progress-indicator-stacked` at `7fb096839d5fd423a981f384227bdd3a08876515` passed. No TodoWrite implementation was copied and no Git dependency was introduced. +Task tools remain standalone render units, so hiding `TaskCreate`、`TaskUpdate`、`TaskList` cannot hide adjacent ordinary tools or alter their activity identity. ## Verification status @@ -59,7 +59,7 @@ The task-progress PR worktree is based on a different stack and changes GUI `row - Focused activity/identity/scroll tests: passed, including 100 appended tools, stable live-to-settled keys, interleaved reasoning/tool/result order, and one-frame pin coalescing. - Mirrored live-caret regression tests assert that neither GUI nor WebUI round content requests a Markdown caret; both focused tests and both builds pass. - GUI/WebUI status-width regression tests assert the non-expanding container chain and full-width truncation target; focused tests, touched-file lint, both builds, Mirror Check, and diff hygiene pass. -- Coverage audit follow-up: mirrored special-tool identity tests now cover `TodoWrite`, `AskUserQuestion`, `Image`, `Agent`, and hosted-search singleton-to-group stability. Gateway row tests now explicitly apply result B before result A, repeat result B, and assert the original A/B order, result ownership, error status, and outer assistant key remain stable. Focused rerun passed GUI 9/9 and WebUI 24/24. +- Coverage audit follow-up: mirrored special-tool identity tests now cover `TaskCreate`、`TaskUpdate`、`TaskList`、`AskUserQuestion`、`Image`、`Agent`, and hosted-search singleton-to-group stability. Gateway row tests explicitly apply result B before result A, repeat result B, and assert the original A/B order, result ownership, error status, and outer assistant key remain stable. - GUI lint: 419 errors / 358 warnings / 9 infos versus baseline 428 / 358 / 9. A final targeted Biome check of all 14 touched GUI source files exited successfully with three existing warnings and no errors. - Gateway WebUI lint: 289 errors / 310 warnings / 10 infos versus baseline 296 / 310 / 10. A final targeted Biome check of all 11 touched WebUI source files exited successfully with 22 existing warnings and no errors. - Mirror Check: 122/122 passed. @@ -102,7 +102,7 @@ All prompts below are read-only unless the row explicitly asks the user to press | Stop/cancel | GUI + WebUI | Send the stop prompt; after the long-running tool begins, press Stop once. | Running item becomes cancelled/aborted in place; no duplicate status row; turn settles once without a final jump. | | Retry | GUI + WebUI | Use the existing retry action on the failed turn exactly once. | A new attempt is represented without reordering the settled prior turn; repeated click is not duplicated. | | AskUserQuestion | GUI + WebUI | Send the question prompt; wait five seconds, select “继续”, submit once. | Pending card and surrounding activities do not move; answering resumes the same turn; duplicate submission is blocked. | -| TodoWrite compatibility | GUI + WebUI | The twelve-step prompt creates and updates the complete TodoWrite list before every step. | Existing TodoWrite bubble/progress behavior remains available; hidden TodoWrite never splits adjacent ordinary tools or changes their identity. | +| Task tool compatibility | GUI + WebUI | The twelve-step prompt creates tasks once and updates each by stable ID. | Hidden task tools never split adjacent ordinary tools or change their identity; the progress snapshot retains stable task IDs. | | Image | GUI + WebUI | Attach a small image and ask the agent to inspect its dimensions/read visible text, without editing files. | Image tool/activity stays at its original position as result arrives; preview/details still open. | | Hosted search | GUI + WebUI | Ask: “使用 hosted search 查找 LiveAgent 仓库主页,只返回标题和 URL。” | Search row updates in place and does not regroup neighboring shell/file tools. | | Subagent | GUI + WebUI | Use the parallel-subagent prompt. | Both subagent activities keep stable identity, progress/result details remain accessible. | @@ -118,9 +118,9 @@ All prompts below are read-only unless the row explicitly asks the user to press ```text 这是实时活动稳定性验收。不要修改任何文件,不要并行、合并、跳过或批量完成步骤。 -1. 首先调用 TodoWrite,一次性创建下面完整的 12 项任务,名称和顺序后续不得改变;只将第 1 项设为 in_progress,其余设为 pending。 -2. 每完成一项,必须立即重新调用 TodoWrite,提交完整 12 项列表:刚完成项设为 completed,下一项设为 in_progress,其余状态保持不变;一次只能完成一项。 -3. 每次 TodoWrite 更新后执行 Start-Sleep -Seconds 2,再执行下一项。 +1. 首先为下面 12 项工作分别调用 TaskCreate,并记录执行器返回的稳定 taskId;创建完成后用 TaskUpdate 将第 1 项设为 in_progress。 +2. 每完成一项,立即用 TaskUpdate 按 taskId 将刚完成项设为 completed、下一项设为 in_progress;不要重建或重排任务。 +3. 每次 TaskUpdate 后执行 Start-Sleep -Seconds 2,再执行下一项。 4. 每项必须使用一次独立工具调用,严格串行: 1) 获取当前工作目录 2) 获取当前 Git 分支 diff --git a/docs/worklog/paste-newline-serialization.md b/docs/worklog/paste-newline-serialization.md index 56f72eed3..c24b742af 100644 --- a/docs/worklog/paste-newline-serialization.md +++ b/docs/worklog/paste-newline-serialization.md @@ -41,7 +41,7 @@ - CRLF 与 CR 可以在一个明确边界规范为 LF,但不得增加、删除或移动逻辑换行。 - 输入、发送和渲染不得分别执行可叠加的换行扩增转换。 - 手工输入与粘贴得到相同逻辑文本时,payload 与渲染必须相同。 -- 用户消息的纯文本换行/块间距策略必须局部生效;assistant、thinking、tool、AskUserQuestion、TodoWrite 和 system 的 Markdown 语义保持不变。 +- 用户消息的纯文本换行/块间距策略必须局部生效;assistant、thinking、tool、AskUserQuestion、任务工具和 system 的 Markdown 语义保持不变。 - GUI、Gateway WebUI 与桌面共用 React 路径保持相同数据和视觉不变量。 - 保留消息 identity、顺序、虚拟化、滚动跟随、composer 布局和任务进度指示器。 - 不用 `trim()`、全局空白折叠、固定高度、隐藏溢出或 O(n²)/同步全量 DOM 遍历掩盖问题。 @@ -94,7 +94,7 @@ ## 人工验收与公开截图 - 2026-08-01:用户按验收矩阵完成测试并明确回复“通过”;因此提交/远端门禁解除。 -- 用户回复后立即尝试捕获 Tauri 当前窗口,但窗口仍停在既有 TodoWrite 会话,没有显示换行样例;该文件 `acceptance-tauri-final.png` 不作为 PR 证据,也不对既有会话做自动切换或发送。 +- 用户回复后立即尝试捕获 Tauri 当前窗口,但窗口仍停在既有任务会话,没有显示换行样例;该文件 `acceptance-tauri-final.png` 不作为 PR 证据,也不对既有会话做自动切换或发送。 - 公开证据改由已通过的实际生产模块 fixture 生成,不修改产品源码、不触发模型调用: - GUI:`target/paste-newline-artifacts/runtime/screenshots/acceptance-gui-pipeline-2026-08-01T01-30-28-742Z.png` - Gateway WebUI:`target/paste-newline-artifacts/runtime/screenshots/acceptance-webui-pipeline-2026-08-01T01-31-45-337Z.png` diff --git a/docs/worklog/task-progress-indicator.md b/docs/worklog/task-progress-indicator.md index f38d20c30..050aa8413 100644 --- a/docs/worklog/task-progress-indicator.md +++ b/docs/worklog/task-progress-indicator.md @@ -1,67 +1,42 @@ -# Current-session TodoWrite task progress indicator +# Durable task progress -## Goal +## 目标 -Project the latest valid full `TodoWrite` list from the current conversation transcript/history and show it as an accessible, mirrored progress pill above the desktop GUI and Gateway WebUI composers. +LiveAgent 的任务清单必须属于当前 Agent Run,而不是属于前端进程或某一段模型上下文。一次 Run 内无论发生多少次上下文压缩,任务的 ID、顺序、内容和状态都保持稳定;下一条用户消息开始新 Run 时才清空。 -## Baseline and isolation +## 权威状态 -- Original development baseline: `upstream/main` at `88e7c5daf31d249453c3a39dd0e50c35219b223f`. -- Original development branch and Worktree: `codex/feat-task-progress-indicator` in `target\codex-task-progress-worktree`. -- The original stacked submission retained commit `580f7b83` from upstream PR #343 without cherry-picking. PR #343 was later closed without merging; PR #345 now intentionally carries that ingress fix as its first commit and declares `Depends-On: none` / `Stack-Root: #345` as the replacement root. -- 2026-08-06 maintenance baseline: latest `upstream/main` at `00a2c6fc43754f40022b0703459824559bee73ea` in the current main workspace on `chore-pr-345-rebase`; no historical Worktree was entered, modified, or cleaned. -- Original PR head `7fb09683` rebased without conflicts to product HEAD `8a0663e4`; both commits are patch-equivalent in `git range-diff`, with the same 28-file `4042 insertions / 61 deletions` scope. -- The main workspace's protected `Cargo.toml`, `.codegraph/`, `output/`, and local-only `start-tauri-dev.bat` state remain outside this PR maintenance change. +| 层级 | 设计 | +|---|---| +| 工具协议 | `TaskCreate` 创建单个任务,`TaskUpdate` 按稳定 `taskId` 更新,`TaskList` 返回完整快照;不存在整表替换接口。 | +| 身份 | 执行器按 `nextTaskId` 分配单调递增数字 ID,模型不能指定或复用 ID。 | +| 并发 | 三个任务工具共享串行队列,避免同一工具回合并发创建时重复分配 ID。 | +| 持久化 | `TaskListState` 写入 `StoredChatContextMeta.taskList`,随现有 `context_meta_json` 和压缩 checkpoint 原子持久化;任务提交走非终态持久化通道,中途写盘失败只属于该次工具调用,不得把成功收尾的 Run 上报为 `history_persist_failed`。 | +| 压缩恢复 | 每次模型请求都从当前会话状态动态注入同一份 `runId/revision/tasks` 权威 JSON,不依赖自由文本摘要恢复任务;注入与工具同口径按 `runId` 门控,异 Run 状态视为不存在。 | +| Run 边界 | `useSendChatTurn` 在追加新用户消息前清除上一 Run 的 `taskList`,edit-resend 替换回来的历史状态同样清除;压缩、工具回合和流中恢复不清除。 | +| Checkpoint 事务 | 追加新 Segment 时,在同一 SQLite 事务中先刷新刚封存的旧活跃 Segment,再插入带 summary 的新 Segment,保证工具消息、任务状态和总消息数同步推进。 | -## Progress +## UI 投影 -- Added mirrored pure transcript projection models for successful results and complete streaming-argument fallback. -- Invalid, partial, and failed settled updates preserve the previous valid snapshot; a valid empty list clears it. -- Added a props-only mirrored progress component with running, pending, interrupted, and completed states. -- Added delayed hover/focus close, Escape close, touch toggle, absolute expansion, reduced-motion handling, narrow/long-list constraints, and ARIA progress state. -- Wired both composer adapters, localized copy, composer slots, and mirror manifest entries. -- After the first manual acceptance pass showed the legacy `TodoWrite` list in the transcript, the user clarified that task lists must live only above the composer. Both transcript renderers now hide every `TodoWrite` block while retaining all other tool blocks and the underlying transcript/history data. -- The desktop adapter now subscribes to the current conversation's live rounds, so the first complete streaming `TodoWrite` arguments project into the pill without waiting for history persistence. Gateway WebUI already projects its combined live and folded rows. -- After the second manual pass exposed rAF-coalesced status jumps, the pure model was extended to retain every valid TodoWrite call snapshot in source order and deduplicate live/history overlap by call id. A mirrored sequencer now displays newly observed real snapshots at 180 ms intervals, while initial/restored history adopts the latest state immediately and live-to-history handoff does not replay. -- After the third manual pass showed that later full-replacement calls could shrink or rewrite the visible roster, the first TodoWrite call now anchors the plan structure. That anchor may finish its own streaming roster, but later calls merge only matched task statuses into the fixed titles, order, length, and positional row identities. A valid empty list resets the anchor for the next plan. Status changes remount only the status visual and keep the task label stable. -- After fixed-roster acceptance passed, the user added a turn lifecycle rule: submitting the next user message immediately hides the previous progress indicator. User transcript rows now project a null progress boundary without mutating history; stale history/live overlap cannot revive a known old TodoWrite key, and the first valid TodoWrite after that boundary starts a fresh plan. A terminal null update is suppressed during the same render rather than waiting one effect frame. -- After Windows acceptance completed, an ignored local-only PowerShell launcher was added at `target\gateway-web-acceptance\start-gateway-web-acceptance.ps1` for Gateway WebUI acceptance. It builds a worktree-local Gateway exe, uses isolated data/log paths, starts Vite from this worktree on ports 50052/5173, verifies process paths and health, opens the browser, refuses occupied ports, and cleans up its owned children on normal exit or Ctrl+C. -- Web narrow-window acceptance then exposed two related first-presentation defects. A real-browser mutation trace showed one streamed `TodoWrite` alternating through valid 1-item, invalid partial, valid 4-item, invalid partial, and final 12-item argument frames, which made the pill follow `1 -> hidden -> 4 -> hidden -> 12`. Projection updates now retain whether a snapshot is a tentative arguments frame or a settled result; the sequencer keeps tentative frames hidden until a successful result arrives, or adopts a complete-arguments fallback only after 240 ms of stability. Invalid and failed frames cancel the fallback without clearing a previous valid snapshot. The collapsed pill also keeps its completed count visible below 420 px instead of leaving a trailing separator. -- Added mirrored projection and component interaction tests. +GUI 与 Gateway WebUI 只读取成功 `TaskCreate`、`TaskUpdate`、`TaskList` 结果中的完整 canonical snapshot。投影不读取流式参数,不按文案或位置猜测身份,也不做延时序列兼容。任务工具块在 transcript 中保持 standalone 并统一隐藏,输入框上方的进度指示器以 `task.id` 作为 React key。 -## Verification checkpoint +## 不变量 -- GUI production build and TypeScript: passed. -- WebUI production build and TypeScript: passed. -- Explicit GUI `tsc --noEmit`: passed. -- Explicit WebUI `tsc --noEmit`: passed. -- WebUI tests: 512/512 passed. -- GUI tests: 1431/1436 passed; the same five unrelated tests fail on unmodified main (1410/1415 passed). -- Focused projection, sequencing, and interaction tests: GUI 21/21 and WebUI 18/18 passed. They cover a 12-item plan receiving a shorter five-item update, same-anchor roster completion, title preservation, reconnect restoration, explicit clear, row-scoped motion, immediate next-user-turn hiding, old live overlap suppression, and fresh-plan recovery. -- Mirror Check: passed for 122 files. -- `git diff --check`: passed. -- Full GUI/WebUI Biome lint still fails on existing main-wide formatting/lint debt and now exactly matches the clean baseline counts: GUI 428 errors / 358 warnings / 9 infos; WebUI 296 errors / 310 warnings / 10 infos. Explicit task-source checks report zero errors. -- The post-flicker-fix focused suites pass: GUI 24/24 and WebUI 21/21. Both production builds, including TypeScript, pass with the tentative/result sequencing and narrow-count changes. -- Post-fix full WebUI tests pass 515/515. Full GUI tests pass 1434/1439 with the same five baseline failures (two mention composer selection source-extraction checks, two mention refetch source-extraction checks, and the provider usage preset byte comparison). -- A fresh worktree-local Gateway executable was built and started on port 50052, then the same 400 px Playwright flow was repeated. Before the fix the observed DOM states were `old 12 -> hidden -> 1 -> hidden -> 4 -> hidden -> 12`; after the fix they are `old 12 -> hidden on user submit -> final 12`, followed only by the expected running-to-paused state transition. The collapsed pill visibly includes `0/12 已完成` at 400 px. -- Gateway launcher smoke: PowerShell parse passed; Gateway `/healthz` and Vite returned HTTP 200; the Vite HTML contained `@vite/client`; PID executable/command lines pointed to this worktree; a timed normal exit released ports 50052/5173 and removed both child processes. The launcher and its runtime directory are ignored and will not be committed. -- Gateway launcher LAN mode: `-Lan` discovers the preferred default-route IPv4, binds Gateway/Vite to all interfaces while keeping the internal Vite proxy on loopback, and prints the LAN WebUI plus desktop Agent settings. Optional elevated `-ConfigureFirewall` creates a Private-profile, LocalSubnet-only temporary rule for the two selected ports and removes it on normal exit. A live smoke on alternate ports bound Gateway/Vite externally and returned HTTP 200 from `192.168.1.2`; the Vite response contained `@vite/client`, and timed exit released every test port. -- Independent read-only review found an unnecessary Escape refocus path; it was removed and retested so Escape closes without a focus-triggered reopen risk. -- A second independent review found no deterministic first-frame, React subscription, accessibility, or result-precedence regression. The raised mixed-tool grouping concern was checked against both grouping implementations and covered by behavior tests: `TodoWrite` is always standalone, so hiding it cannot hide adjacent ordinary tools. -- Sequencer review confirmed timers, conversation-key reset, clear semantics, history hydration, and handoff coverage. Same-call-id blocks are intentionally one logical tool invocation (result replaces streaming args); distinct TodoWrite calls retain distinct ids and are sequenced. Snapshot signatures also suppress visible duplication when legacy data lacks ids. +| 不变量 | 保证方式 | +|---|---| +| 压缩不能创建新计划 | 权威状态位于会话元数据;压缩摘要不拥有任务生命周期。 | +| 更新不能改变其他任务身份 | `TaskUpdate` 必须提供现有 `taskId`,只修改明确给出的字段。 | +| 最多一个进行中任务 | 执行器拒绝会产生多个 `in_progress` 的更新。 | +| 工具成功必须可恢复 | 先落盘、成功后才应用到运行时状态;失败时状态从未变更,直接返回错误。 | +| 损坏数据不阻塞会话 | 历史 `taskList` 解析失败按丢弃降级并告警,绝不让整个会话窗口无法打开。 | +| 压缩成功必须已落盘 | checkpoint 持久化返回 `false` 时按压缩失败处理,禁止切换运行时 Segment 或发布 checkpoint。 | +| 双端显示一致 | 共享 `taskProgress.ts` 只接受 canonical result details,GUI/WebUI 使用同一投影和组件。 | -## Resume +## 验证 -Manual acceptance passed in both Gateway WebUI and the freshly restarted Windows Tauri client. The user confirmed that initial plan creation no longer flickers, progress rows update in place, the next submitted turn hides the previous indicator, and the narrow Web pill retains its completed count. The user then explicitly authorized commit, push, and upstream PR creation. - -## 2026-08-06 rebase verification - -- Range and overlap audit: rebased the two-commit PR from merge base `849daf26` onto `upstream/main@00a2c6fc`; old/new file sets and stats are identical, both commits are `=` in `git range-diff`, and the six files also touched by newer main changes retain the main tree plus the original PR patch. -- Focused validation: Gateway `internal/session` and `test/websocket` passed; GUI task-progress/row-model tests passed 40/40; Gateway WebUI task-progress/assistant-bubble tests passed 23/23. -- Full validation: Gateway WebUI 515/515; GUI 1422/1427, with the same five failures reproduced on a fresh non-Worktree `upstream/main@00a2c6fc` snapshot at 1398/1403. The failures remain the two mention-selection source extractors, two mention-refetch source extractors, and one Rust/TypeScript preset byte comparison. -- Gateway full tests pass every package except the Windows-only agent-token file-mode assertion (`0666`, expected POSIX `0600`); the same package fails identically on the latest-main snapshot. Gateway task packages and `go vet ./...` pass. -- GUI and Gateway WebUI production builds pass. `cargo check --manifest-path crates/agent-gui/src-tauri/Cargo.toml --tests` passes after resolving `LIBCLANG_PATH` through the protected launcher's existing Python clang path, with five unrelated unused/dead-code warnings. -- Full lint remains baseline-bound on the Windows CRLF checkout: GUI current `423 errors / 358 warnings / 9 infos` versus the previously recorded same-main baseline `424 / 358 / 9`; WebUI current and baseline are both `292 / 310 / 10`. Focused production-file lint exits zero on both clients (warnings only in existing adapter code). -- Mirror Check passes for 118 files and `git diff --check upstream/main..HEAD` passes. -- Same-HEAD Tauri acceptance ran from `chore-pr-345-rebase@8a0663e4`: Vite returned HTTP 200 and the current-workspace `target/debug/liveagent.exe` window remained responsive. -- The user explicitly confirmed `PR #345 通过` on 2026-08-06 after the rebase acceptance matrix covering the 12-item no-flicker flow, hover/focus/touch/Escape and narrow-window behavior, next-turn clearing, stop/continue recovery, and conversation/history restoration. This authorizes the guarded amend and exact force-with-lease update of the PR branch. +- 任务工具测试覆盖 schema、稳定 ID、并发创建、按 ID 更新、单一进行中任务、只读列表和持久化失败。 +- 历史测试覆盖 `context_meta_json` 中任务状态的严格解析与恢复,以及损坏任务清单降级为丢弃而不阻塞窗口打开。 +- 压缩控制器测试覆盖连续两个 checkpoint 后 `runId/revision/tasks` 完全一致。 +- 历史持久化测试覆盖 checkpoint 原子刷新封存段并追加新段,以及持久化拒绝时不切换运行时 Segment。 +- GUI/WebUI 投影测试覆盖成功结果优先、忽略半截参数/失败结果、用户 Run 边界和 transcript 过滤。 +- GUI 与 WebUI 全量前端测试、双端 TypeScript、生产构建、镜像检查和 UI 边界检查均作为合入门禁。