From db6520bf368c2f0a0ec3163dc83a7968cbbe74fe Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 18:23:30 +0200 Subject: [PATCH] Show live task progress while agents work (#3227) * feat(agents): surface live task progress Keep current task state above the composer and turn task snapshots into semantic timeline activity. Normalize provider task protocols while preserving genuine plan flows. * fix(agents): admit tasks through stream sequencing * test(agents): expect normalized task status --- .../browser/provider-task-list.real.spec.ts | 116 ++++++++++ packages/app/e2e/support/helpers/task-list.ts | 24 +++ packages/app/src/agent-stream/view.tsx | 2 +- packages/app/src/components/message.tsx | 62 +++++- packages/app/src/composer/task-list/index.tsx | 163 ++++++++++++++ packages/app/src/i18n/resources.test.ts | 8 + packages/app/src/i18n/resources/ar.ts | 9 + packages/app/src/i18n/resources/en.ts | 9 + packages/app/src/i18n/resources/es.ts | 9 + packages/app/src/i18n/resources/fr.ts | 9 + packages/app/src/i18n/resources/ja.ts | 9 + packages/app/src/i18n/resources/ko.ts | 9 + packages/app/src/i18n/resources/pt-BR.ts | 9 + packages/app/src/i18n/resources/ru.ts | 9 + packages/app/src/i18n/resources/zh-CN.ts | 9 + packages/app/src/panels/agent-panel.tsx | 2 + packages/app/src/stores/session-store.test.ts | 56 +++++ packages/app/src/stores/session-store.ts | 38 +++- .../timeline/session-stream-reducers.test.ts | 53 +++++ .../src/timeline/session-stream-reducers.ts | 16 +- packages/app/src/types/stream.test.ts | 107 +++++++++- packages/app/src/types/stream.ts | 124 +++++++++-- packages/protocol/src/agent-types.ts | 10 +- packages/protocol/src/messages.ts | 3 + .../protocol/src/messages.wire-compat.test.ts | 34 +++ .../src/server/agent/agent-sdk-types.ts | 5 +- .../agent/providers/claude/agent.test.ts | 47 ++++ .../server/agent/providers/claude/agent.ts | 14 +- .../agent/providers/claude/task-state.test.ts | 127 +++++++++++ .../agent/providers/claude/task-state.ts | 202 ++++++++++++++++++ .../providers/codex-app-server-agent.test.ts | 20 ++ .../agent/providers/codex-app-server-agent.ts | 38 +++- .../agent/providers/omp/todo-mapper.test.ts | 24 +-- .../server/agent/providers/omp/todo-mapper.ts | 7 + .../agent/providers/opencode-agent.test.ts | 8 +- .../server/agent/providers/opencode-agent.ts | 7 + .../opencode/event-translator.test.ts | 4 +- 37 files changed, 1342 insertions(+), 60 deletions(-) create mode 100644 packages/app/e2e/browser/provider-task-list.real.spec.ts create mode 100644 packages/app/e2e/support/helpers/task-list.ts create mode 100644 packages/app/src/composer/task-list/index.tsx create mode 100644 packages/server/src/server/agent/providers/claude/task-state.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/task-state.ts diff --git a/packages/app/e2e/browser/provider-task-list.real.spec.ts b/packages/app/e2e/browser/provider-task-list.real.spec.ts new file mode 100644 index 000000000..b388b7d2c --- /dev/null +++ b/packages/app/e2e/browser/provider-task-list.real.spec.ts @@ -0,0 +1,116 @@ +import { mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "../support/fixtures"; +import { + cleanupRewindFlow, + launchAgent, + sendMessage, + type AgentHandle, + type RewindFlowProvider, +} from "../support/helpers/rewind-flow"; +import { + expectCompletedTaskActivity, + expectTaskListEntries, + openCompletedTaskList, +} from "../support/helpers/task-list"; +import { submitMessage } from "../support/helpers/composer"; + +interface TaskScenario { + provider: RewindFlowProvider; + entries: [string, string]; + prompt: string; + providerConfig?: Parameters[0]["providerConfig"]; +} + +const scenarios: TaskScenario[] = [ + { + provider: "claude", + entries: ["Claude inventory alpha", "Claude inventory beta"], + providerConfig: { model: "opus" }, + prompt: + "Use TaskCreate to create exactly two tasks named Claude inventory alpha and Claude inventory beta. Use TaskUpdate to mark each in_progress and then completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.", + }, + { + provider: "codex", + entries: ["Codex inventory alpha", "Codex inventory beta"], + prompt: + "Use update_plan to track exactly two steps named Codex inventory alpha and Codex inventory beta. Move each through in_progress and completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.", + }, + { + provider: "opencode", + entries: ["OpenCode inventory alpha", "OpenCode inventory beta"], + prompt: + "Use TodoWrite to track exactly two tasks named OpenCode inventory alpha and OpenCode inventory beta. Move each through in_progress and completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.", + }, +]; + +test.describe("real provider composer task lists", () => { + test.setTimeout(600_000); + + for (const scenario of scenarios) { + test(`${scenario.provider} shows native task progress above the composer`, async ({ + page, + }, testInfo) => { + const cwd = realpathSync( + mkdtempSync(path.join(tmpdir(), `paseo-task-list-${scenario.provider}-`)), + ); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ + page, + provider: scenario.provider, + cwd, + mode: "full-access", + providerConfig: scenario.providerConfig, + }); + await sendMessage(handle, scenario.prompt); + await openCompletedTaskList(page, scenario.entries.length); + await expectTaskListEntries(page, scenario.entries); + await expectCompletedTaskActivity(page, scenario.entries); + await expect(page.getByText("TaskUpdate", { exact: true })).toHaveCount(0); + await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0); + + const screenshot = testInfo.outputPath(`${scenario.provider}-composer-tasks.png`); + await page.screenshot({ path: screenshot }); + await testInfo.attach(`${scenario.provider} composer tasks`, { + path: screenshot, + contentType: "image/png", + }); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); + } + + test("Codex genuine Plan mode keeps the existing plan card", async ({ page }, testInfo) => { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-genuine-codex-plan-"))); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ + page, + provider: "codex", + cwd, + mode: "full-access", + providerConfig: { featureValues: { plan_mode: true } }, + }); + await submitMessage( + page, + "Produce a concise implementation plan with exactly these steps: Inspect genuine plan boundary; Implement genuine plan boundary; Verify genuine plan boundary. Do not implement it.", + ); + + const planCard = page.getByTestId("permission-plan-card"); + await expect(planCard).toBeVisible({ timeout: 120_000 }); + await expect(planCard).toContainText("Inspect genuine plan boundary"); + await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0); + + const screenshot = testInfo.outputPath("codex-genuine-plan.png"); + await page.screenshot({ path: screenshot }); + await testInfo.attach("Codex genuine plan", { path: screenshot, contentType: "image/png" }); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/task-list.ts b/packages/app/e2e/support/helpers/task-list.ts new file mode 100644 index 000000000..368e279fc --- /dev/null +++ b/packages/app/e2e/support/helpers/task-list.ts @@ -0,0 +1,24 @@ +import { expect, type Page } from "@playwright/test"; + +export async function openCompletedTaskList(page: Page, count: number): Promise { + const taskButton = page.getByRole("button", { name: `${count}/${count} tasks` }); + await expect(taskButton).toBeVisible({ timeout: 60_000 }); + await taskButton.click(); +} + +export async function expectTaskListEntries(page: Page, entries: readonly string[]): Promise { + for (const entry of entries) { + await expect(page.getByLabel(entry, { exact: true })).toBeVisible(); + } +} + +export async function expectCompletedTaskActivity( + page: Page, + entries: readonly string[], +): Promise { + for (const entry of entries) { + await expect( + page.getByRole("button", { name: `Completed ${entry}`, exact: true }), + ).toBeVisible(); + } +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 7203ca380..58174eae1 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -799,7 +799,7 @@ const AgentStreamViewComponent = forwardRef; + return ; case "compaction": return ( diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 1b19994c7..b6a0162fd 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -41,7 +41,10 @@ import { ChevronDown, Check, CheckSquare, + CircleDot, Copy, + Plus, + RotateCcw, TriangleAlertIcon, Scissors, MicVocal, @@ -62,7 +65,7 @@ import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "reac import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer"; -import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream"; +import type { TaskActivity, TodoEntry, UserMessageImageAttachment } from "@/types/stream"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { buildToolCallPresentation } from "@/tool-calls/presentation"; @@ -2240,15 +2243,32 @@ export const CompactionMarker = memo(function CompactionMarker({ interface TodoListCardProps { items: TodoEntry[]; + activity: TaskActivity; disableOuterSpacing?: boolean; } interface TodoListItemRowProps { text: string; completed: boolean; + status?: TodoEntry["status"]; } -function TodoListItemRow({ text, completed }: TodoListItemRowProps) { +function taskActivityIcon(activity: TaskActivity) { + switch (activity.type) { + case "added": + return Plus; + case "started": + return CircleDot; + case "completed": + return Check; + case "reopened": + return RotateCcw; + default: + return CheckSquare; + } +} + +function TodoListItemRow({ text, completed, status }: TodoListItemRowProps) { const badgeStyle = useMemo( () => [ todoListCardStylesheet.radioBadge, @@ -2264,7 +2284,9 @@ function TodoListItemRow({ text, completed }: TodoListItemRowProps) { ); return ( - + {completed ? ( ) : null} @@ -2300,6 +2322,12 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({ radioBadgeComplete: { opacity: 0.95, }, + radioBadgeActive: { + borderWidth: 2, + borderColor: theme.colors.primary, + backgroundColor: "transparent", + opacity: 1, + }, itemText: { flex: 1, color: theme.colors.foreground, @@ -2317,12 +2345,23 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({ export const TodoListCard = memo(function TodoListCard({ items, + activity, disableOuterSpacing, }: TodoListCardProps) { const { t } = useTranslation(); const [isExpanded, setIsExpanded] = useState(false); - - const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]); + const activityDisplay = useMemo(() => { + if (activity.type === "created") { + return { + label: t("message.todo.activity.created", { count: activity.count }), + secondaryLabel: undefined, + }; + } + return { + label: t(`message.todo.activity.${activity.type}`), + secondaryLabel: activity.task, + }; + }, [activity, t]); const handleToggle = useCallback(() => { setIsExpanded((prev) => !prev); @@ -2336,7 +2375,12 @@ export const TodoListCard = memo(function TodoListCard({ {t("message.todo.empty")} ) : ( items.map((item) => ( - + )) )} @@ -2346,9 +2390,9 @@ export const TodoListCard = memo(function TodoListCard({ return ( ({ color: theme.colors.foregroundMuted }); +const activeIcon = (theme: Theme) => ({ color: theme.colors.statusWarning }); +const completedIcon = (theme: Theme) => ({ color: theme.colors.statusSuccess }); + +export const AgentTaskList = memo(function AgentTaskList({ + serverId, + agentId, +}: { + serverId: string; + agentId: string; +}) { + const tasks = useSessionStore((state) => state.sessions[serverId]?.agentTasks.get(agentId)); + if (!tasks?.length) return null; + return ; +}); + +function TaskStatusIcon({ task }: { task: TodoEntry }) { + if (task.completed || task.status === "completed") { + return ; + } + if (task.status === "in_progress") { + return ; + } + return ; +} + +const TaskListCard = memo(function TaskListCard({ tasks }: { tasks: TodoEntry[] }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const completed = useMemo( + () => tasks.filter((task) => task.completed || task.status === "completed").length, + [tasks], + ); + const currentTask = useMemo( + () => + tasks.find((task) => task.status === "in_progress") ?? + tasks.find((task) => !task.completed && task.status !== "completed"), + [tasks], + ); + const currentTaskText = + currentTask?.status === "in_progress" && currentTask.activeForm + ? currentTask.activeForm + : currentTask?.text; + const label = currentTaskText + ? t("message.todo.tasksProgressCurrent", { + completed, + total: tasks.length, + task: currentTaskText, + }) + : t("message.todo.tasksProgress", { completed, total: tasks.length }); + const toggle = useCallback(() => setExpanded((current) => !current), []); + const accessibilityState = useMemo(() => ({ expanded }), [expanded]); + + return ( + + + + + {expanded ? ( + + {tasks.map((task, index) => { + const isActive = task.status === "in_progress"; + const text = isActive && task.activeForm ? task.activeForm : task.text; + return ( + + + + {text} + + + ); + })} + + ) : null} + + + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + alignItems: "center", + paddingHorizontal: theme.spacing[4], + }, + track: { + width: "100%", + maxWidth: MAX_CONTENT_WIDTH, + marginBottom: -theme.spacing[4], + }, + card: { + backgroundColor: theme.colors.surface1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderBottomWidth: 0, + borderTopLeftRadius: theme.borderRadius["2xl"], + borderTopRightRadius: theme.borderRadius["2xl"], + overflow: "hidden", + paddingBottom: theme.spacing[4], + }, + header: { + width: "100%", + justifyContent: "flex-start", + borderRadius: 0, + paddingHorizontal: theme.spacing[3], + }, + headerText: { + color: theme.colors.foregroundMuted, + }, + list: { + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.border, + paddingTop: theme.spacing[1], + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + }, + taskText: { + flex: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + completedText: { + color: theme.colors.foregroundMuted, + textDecorationLine: "line-through", + }, +})); diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 55b5995ee..20de9d046 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -393,6 +393,14 @@ describe("translation resources", () => { expect(en.message.question.otherPlaceholder).toBe("Other..."); expect(en.message.todo.title).toBe("Tasks"); expect(en.message.todo.empty).toBe("No tasks yet."); + expect(en.message.todo.tasksProgressCurrent).toBe("{{completed}}/{{total}} tasks · {{task}}"); + expect(en.message.todo.activity).toEqual({ + created: "Created {{count}} tasks", + added: "Added", + started: "Started", + completed: "Completed", + reopened: "Reopened", + }); }); it("includes workspace tab toast keys for the Batch 4J migration", () => { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index a3baaf06c..1f0d16ec0 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -333,6 +333,15 @@ export const ar: TranslationResources = { todo: { title: "المهام", empty: "لا توجد مهام حتى الآن.", + tasksProgress: "{{completed}}/{{total}} مهام", + tasksProgressCurrent: "{{completed}}/{{total}} مهام · {{task}}", + activity: { + created: "تم إنشاء {{count}} مهام", + added: "أُضيفت", + started: "بدأت", + completed: "اكتملت", + reopened: "أُعيد فتحها", + }, }, compaction: { loading: "الضغط...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 520a43199..477051f2e 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -332,6 +332,15 @@ export const en = { todo: { title: "Tasks", empty: "No tasks yet.", + tasksProgress: "{{completed}}/{{total}} tasks", + tasksProgressCurrent: "{{completed}}/{{total}} tasks · {{task}}", + activity: { + created: "Created {{count}} tasks", + added: "Added", + started: "Started", + completed: "Completed", + reopened: "Reopened", + }, }, compaction: { loading: "Compacting...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index c811ca446..1b32460d3 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -336,6 +336,15 @@ export const es: TranslationResources = { todo: { title: "Tareas", empty: "Aún no hay tareas.", + tasksProgress: "{{completed}}/{{total}} tareas", + tasksProgressCurrent: "{{completed}}/{{total}} tareas · {{task}}", + activity: { + created: "Se crearon {{count}} tareas", + added: "Añadida", + started: "Iniciada", + completed: "Completada", + reopened: "Reabierta", + }, }, compaction: { loading: "Compactando...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 22d4bec50..b6da76799 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -337,6 +337,15 @@ export const fr: TranslationResources = { todo: { title: "Tâches", empty: "Aucune tâche pour l'instant.", + tasksProgress: "{{completed}}/{{total}} tâches", + tasksProgressCurrent: "{{completed}}/{{total}} tâches · {{task}}", + activity: { + created: "{{count}} tâches créées", + added: "Ajoutée", + started: "Commencée", + completed: "Terminée", + reopened: "Rouverte", + }, }, compaction: { loading: "Compactage...", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index e6dd25e26..f10037ef7 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -336,6 +336,15 @@ export const ja: TranslationResources = { todo: { title: "タスク", empty: "タスクがまだありません。", + tasksProgress: "{{completed}}/{{total}}タスク", + tasksProgressCurrent: "{{completed}}/{{total}}タスク · {{task}}", + activity: { + created: "{{count}}件のタスクを作成", + added: "追加", + started: "開始", + completed: "完了", + reopened: "再開", + }, }, compaction: { loading: "コンテキストを圧縮中...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index e6491869b..66dc58c16 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -334,6 +334,15 @@ export const ko: TranslationResources = { todo: { title: "작업", empty: "아직 작업이 없습니다.", + tasksProgress: "작업 {{completed}}/{{total}}개", + tasksProgressCurrent: "작업 {{completed}}/{{total}}개 · {{task}}", + activity: { + created: "작업 {{count}}개 생성", + added: "추가됨", + started: "시작됨", + completed: "완료됨", + reopened: "다시 열림", + }, }, compaction: { loading: "압축하는 중...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 21b4091ea..c87eed9a1 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -336,6 +336,15 @@ export const ptBR: TranslationResources = { todo: { title: "Tarefas", empty: "Nenhuma tarefa ainda.", + tasksProgress: "{{completed}}/{{total}} tarefas", + tasksProgressCurrent: "{{completed}}/{{total}} tarefas · {{task}}", + activity: { + created: "{{count}} tarefas criadas", + added: "Adicionada", + started: "Iniciada", + completed: "Concluída", + reopened: "Reaberta", + }, }, compaction: { loading: "Compactando...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 7ac503334..33d54be3d 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -335,6 +335,15 @@ export const ru: TranslationResources = { todo: { title: "Задачи", empty: "Заданий пока нет.", + tasksProgress: "{{completed}}/{{total}} задач", + tasksProgressCurrent: "{{completed}}/{{total}} задач · {{task}}", + activity: { + created: "Создано задач: {{count}}", + added: "Добавлена", + started: "Начата", + completed: "Завершена", + reopened: "Возобновлена", + }, }, compaction: { loading: "Уплотнение...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index e44f6c103..2283dad92 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -333,6 +333,15 @@ export const zhCN: TranslationResources = { todo: { title: "任务", empty: "还没有任务。", + tasksProgress: "{{completed}}/{{total}} 项任务", + tasksProgressCurrent: "{{completed}}/{{total}} 项任务 · {{task}}", + activity: { + created: "已创建 {{count}} 项任务", + added: "已添加", + started: "已开始", + completed: "已完成", + reopened: "已重新打开", + }, }, compaction: { loading: "正在压缩...", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index ae58e8923..24a85b307 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -74,6 +74,7 @@ import { deriveRouteBottomAnchorRequest, } from "@/screens/agent/agent-ready-screen-bottom-anchor"; import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab"; +import { AgentTaskList } from "@/composer/task-list"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { usePanelStore } from "@/stores/panel-store"; @@ -1612,6 +1613,7 @@ function ActiveAgentComposer({ return ( + (values: readonly T[]): T[][] { ); } +function taskTexts(tasks: ReadonlyArray<{ text: string }>): string[] { + return tasks.map((task) => task.text); +} + +describe("agent task state", () => { + it("notifies the task subscriber only when its task snapshot changes", () => { + initializeTestSession(); + const snapshots: string[][] = []; + const unsubscribe = useSessionStore.subscribe( + (state) => state.sessions["test-server"]?.agentTasks.get("agent-1") ?? [], + (tasks) => snapshots.push(taskTexts(tasks)), + ); + + const tasks = [{ id: "1", text: "Inspect", status: "pending" as const, completed: false }]; + useSessionStore + .getState() + .setAgentStreamState("test-server", "agent-1", { taskSnapshot: tasks }); + useSessionStore.getState().setMessages("test-server", []); + useSessionStore + .getState() + .setAgentStreamState("test-server", "agent-1", { taskSnapshot: [...tasks] }); + useSessionStore.getState().setAgentStreamState("test-server", "agent-1", { + taskSnapshot: [{ ...tasks[0], status: "completed", completed: true }], + }); + unsubscribe(); + + expect(snapshots).toEqual([["Inspect"], ["Inspect"]]); + }); + + it("restores the latest task snapshot from an authoritative timeline", () => { + initializeTestSession(); + const todo: StreamItem = { + kind: "todo_list", + id: "todo-1", + provider: "codex", + timestamp: new Date("2026-08-11T10:00:00.000Z"), + activity: { type: "started", task: "Verify" }, + items: [{ text: "Verify", status: "in_progress", completed: false }], + }; + + useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", { + items: [todo], + head: [], + range: null, + older: "none", + newer: false, + synchronized: true, + acknowledgedClientMessageIds: [], + }); + + expect(useSessionStore.getState().sessions["test-server"]?.agentTasks.get("agent-1")).toEqual( + todo.items, + ); + }); +}); + describe("agent timeline state", () => { it("commits canonical items, range, and older availability as one synced state", () => { initializeTestSession(); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 544507dfd..c8ca04cb2 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -9,6 +9,7 @@ import { handoffCreatedAgentUserMessageToStream, removeSubmittedUserMessage, type StreamItem, + type TodoEntry, type UserMessageItem, } from "@/types/stream"; import { @@ -394,6 +395,26 @@ export function selectAgentTurnPresentation( ); } +function latestTasksFromStream(items: readonly StreamItem[]): TodoEntry[] { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item?.kind === "todo_list") return item.items; + } + return []; +} + +function updateAgentTasks( + current: Map, + agentId: string, + taskSnapshot: TodoEntry[] | undefined, +): Map { + if (taskSnapshot === undefined || equal(current.get(agentId) ?? [], taskSnapshot)) return current; + const next = new Map(current); + if (taskSnapshot.length > 0) next.set(agentId, taskSnapshot); + else next.delete(agentId); + return next; +} + export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade"; // Per-session state @@ -426,6 +447,7 @@ export interface SessionState { // Stream state (head/tail model) agentStreamTail: Map; agentStreamHead: Map; + agentTasks: Map; agentTurnLiveness: Map; messageSubmissions: Map; agentTimelineCursor: Map; @@ -523,6 +545,7 @@ interface SessionStoreActions { tail?: StreamItem[]; head?: StreamItem[]; acknowledgedClientMessageIds?: readonly string[]; + taskSnapshot?: TodoEntry[]; }, ) => void; applyAgentTurnLiveness: ( @@ -692,6 +715,7 @@ function createInitialSessionState( currentAssistantMessage: "", agentStreamTail: new Map(), agentStreamHead: new Map(), + agentTasks: new Map(), agentTurnLiveness: new Map(), messageSubmissions: new Map(), agentTimelineCursor: new Map(), @@ -812,8 +836,11 @@ export const useSessionStore = create()( const session = createInitialSessionState(serverId, null); const timeline = replica.timeline; const agentStreamTail = new Map(); + const agentTasks = new Map(); if (timeline) { agentStreamTail.set(timeline.agentId, timeline.items); + const tasks = latestTasksFromStream(timeline.items); + if (tasks.length > 0) agentTasks.set(timeline.agentId, tasks); } const agentLastActivity = new Map(prev.agentLastActivity); for (const agent of replica.agents.values()) { @@ -830,6 +857,7 @@ export const useSessionStore = create()( workspaces: replica.workspaces, projects: replica.projects, agentStreamTail, + agentTasks, }, }, agentLastActivity, @@ -1149,8 +1177,10 @@ export const useSessionStore = create()( state.acknowledgedClientMessageIds ?? [], ); const changedSubmissions = observedSubmissions !== currentSubmissions; + const agentTasks = updateAgentTasks(session.agentTasks, agentId, state.taskSnapshot); + const changedTasks = agentTasks !== session.agentTasks; - if (!changedTail && !changedHead && !changedSubmissions) { + if (!changedTail && !changedHead && !changedSubmissions && !changedTasks) { return prev; } @@ -1172,6 +1202,7 @@ export const useSessionStore = create()( ...session, agentStreamTail: nextTail, agentStreamHead: nextHead, + agentTasks, messageSubmissions, }, }, @@ -1601,6 +1632,10 @@ export const useSessionStore = create()( nextAuthoritative.set(agentId, true); nextSyncGeneration.set(agentId, session.historySyncGeneration); } + const tasks = latestTasksFromStream([...state.items, ...state.head]); + const agentTasks = new Map(session.agentTasks); + if (tasks.length > 0) agentTasks.set(agentId, tasks); + else agentTasks.delete(agentId); return { ...prev, @@ -1610,6 +1645,7 @@ export const useSessionStore = create()( ...session, agentStreamTail: nextTail, agentStreamHead: nextHead, + agentTasks, agentTimelineCursor: nextCursor, agentTimelineHasOlder: nextHasOlder, agentTimelineHasNewer: nextHasNewer, diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index c89855105..a0daa3db1 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -3557,6 +3557,59 @@ describe("processAgentStreamEvent", () => { expect(result.sideEffects).toEqual([]); }); + it("publishes task snapshots only from accepted timeline events", () => { + const tasks = [ + { id: "inspect", text: "Inspect", status: "in_progress" as const, completed: false }, + ]; + const event: AgentStreamEventPayload = { + type: "timeline", + provider: "codex", + item: { + type: "todo", + items: tasks, + }, + }; + const currentCursor: TimelineCursor = { + epoch: "epoch-1", + startSeq: 1, + endSeq: 4, + }; + + const accepted = processAgentStreamEvent({ + ...baseStreamInput, + event, + seq: 5, + epoch: "epoch-1", + currentCursor, + }); + const stale = processAgentStreamEvent({ + ...baseStreamInput, + event, + seq: 4, + epoch: "epoch-1", + currentCursor, + }); + const wrongEpoch = processAgentStreamEvent({ + ...baseStreamInput, + event, + seq: 5, + epoch: "epoch-2", + currentCursor, + }); + const gapped = processAgentStreamEvent({ + ...baseStreamInput, + event, + seq: 8, + epoch: "epoch-1", + currentCursor, + }); + + expect(accepted.taskSnapshot).toEqual(tasks); + expect(stale.taskSnapshot).toBeUndefined(); + expect(wrongEpoch.taskSnapshot).toBeUndefined(); + expect(gapped.taskSnapshot).toBeUndefined(); + }); + it("detects gap and emits catch-up side effect", () => { const existingCursor: TimelineCursor = { epoch: "epoch-1", diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 158a8ead8..4d6515ada 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1,6 +1,6 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; -import type { AssistantMessageItem, StreamItem } from "@/types/stream"; +import type { AssistantMessageItem, StreamItem, TodoEntry } from "@/types/stream"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; import { applyStreamEvent, @@ -1436,6 +1436,7 @@ export interface ProcessAgentStreamEventOutput { cursor: TimelineCursor | null; cursorChanged: boolean; acknowledgedClientMessageIds: string[]; + taskSnapshot?: TodoEntry[]; sideEffects: AgentStreamReducerSideEffect[]; } @@ -1642,6 +1643,9 @@ export function processAgentStreamEvent( cursor: sequencing.nextTimelineCursor, cursorChanged: sequencing.cursorChanged, acknowledgedClientMessageIds: streamResult.acknowledgedClientMessageIds ?? [], + ...(sequencing.shouldApplyStreamEvent && event.type === "timeline" && event.item.type === "todo" + ? { taskSnapshot: event.item.items } + : {}), sideEffects: sequencing.sideEffects, }; } @@ -1667,6 +1671,7 @@ export function processAgentStreamEvents( let changedTail = false; let changedHead = false; let cursorChanged = false; + let taskSnapshot: TodoEntry[] | undefined; const acknowledgedClientMessageIds = new Set(); const sideEffects: AgentStreamReducerSideEffect[] = []; @@ -1690,6 +1695,9 @@ export function processAgentStreamEvents( for (const clientMessageId of result.acknowledgedClientMessageIds) { acknowledgedClientMessageIds.add(clientMessageId); } + if (result.taskSnapshot !== undefined) { + taskSnapshot = result.taskSnapshot; + } if (result.cursorChanged) { cursor = result.cursor ?? undefined; @@ -1705,6 +1713,7 @@ export function processAgentStreamEvents( cursor: cursor ?? null, cursorChanged, acknowledgedClientMessageIds: [...acknowledgedClientMessageIds], + ...(taskSnapshot !== undefined ? { taskSnapshot } : {}), sideEffects, }; } @@ -1788,6 +1797,7 @@ interface StreamStatePatch { tail?: StreamItem[]; head?: StreamItem[]; acknowledgedClientMessageIds?: readonly string[]; + taskSnapshot?: TodoEntry[]; } export function deriveAgentStreamTurnLiveness( @@ -1850,7 +1860,8 @@ export function createSessionAgentStreamReducerQueue( if ( result.changedTail || result.changedHead || - result.acknowledgedClientMessageIds.length > 0 + result.acknowledgedClientMessageIds.length > 0 || + result.taskSnapshot !== undefined ) { setAgentStreamState(serverId, agentId, { ...(result.changedTail ? { tail: result.tail } : {}), @@ -1858,6 +1869,7 @@ export function createSessionAgentStreamReducerQueue( ...(result.acknowledgedClientMessageIds.length > 0 ? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds } : {}), + ...(result.taskSnapshot !== undefined ? { taskSnapshot: result.taskSnapshot } : {}), }); } if (result.cursorChanged && result.cursor) { diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index c12d45253..3b0229e2a 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -192,10 +192,19 @@ function canonicalToolTimeline(params: { }; } -function todoTimeline(items: { text: string; completed: boolean }[]): AgentStreamEventPayload { +function todoTimeline( + items: Array<{ + id?: string; + text: string; + completed: boolean; + status?: "pending" | "in_progress" | "completed"; + activeForm?: string; + }>, + provider: AgentProvider = "codex", +): AgentStreamEventPayload { return { type: "timeline", - provider: "codex", + provider, item: { type: "todo", items, @@ -970,6 +979,80 @@ describe("stream reducer canonical tool calls", () => { assert.ok(todos); assert.strictEqual(todos.items.length, 2); assert.strictEqual(todos.items[1]?.completed, true); + assert.deepStrictEqual(todos.activity, { type: "created", count: 2 }); + }); + + it("turns task snapshots into semantic timeline activity", () => { + const state = hydrateStreamState([ + { + event: todoTimeline([ + { id: "a", text: "Inspect provider", completed: false, status: "pending" }, + { id: "b", text: "Ship fix", completed: false, status: "pending" }, + ]), + timestamp: new Date("2025-01-01T10:50:00Z"), + }, + { + event: todoTimeline([ + { id: "a", text: "Inspect provider", completed: false, status: "in_progress" }, + { id: "b", text: "Ship fix", completed: false, status: "pending" }, + ]), + timestamp: new Date("2025-01-01T10:50:01Z"), + }, + { + event: todoTimeline([ + { id: "a", text: "Inspect provider", completed: true, status: "completed" }, + { id: "b", text: "Ship fix", completed: false, status: "in_progress" }, + ]), + timestamp: new Date("2025-01-01T10:50:02Z"), + }, + { + event: todoTimeline([ + { id: "a", text: "Inspect provider", completed: true, status: "completed" }, + { id: "b", text: "Ship fix", completed: true, status: "completed" }, + ]), + timestamp: new Date("2025-01-01T10:50:03Z"), + }, + ]); + + expect(state.flatMap((item) => (item.kind === "todo_list" ? [item.activity] : []))).toEqual([ + { type: "created", count: 2 }, + { type: "started", task: "Inspect provider" }, + { type: "completed", task: "Inspect provider" }, + { type: "started", task: "Ship fix" }, + { type: "completed", task: "Ship fix" }, + ]); + }); + + it("groups consecutive initial Claude TaskCreate snapshots", () => { + const state = hydrateStreamState([ + { + event: todoTimeline( + [{ id: "a", text: "Inspect provider", completed: false, status: "pending" }], + "claude", + ), + timestamp: new Date("2025-01-01T10:50:00Z"), + }, + { + event: todoTimeline( + [ + { id: "a", text: "Inspect provider", completed: false, status: "pending" }, + { id: "b", text: "Ship fix", completed: false, status: "pending" }, + ], + "claude", + ), + timestamp: new Date("2025-01-01T10:50:01Z"), + }, + ]); + + expect(state.filter((item) => item.kind === "todo_list")).toEqual([ + expect.objectContaining({ + activity: { type: "created", count: 2 }, + items: expect.arrayContaining([ + expect.objectContaining({ text: "Inspect provider" }), + expect.objectContaining({ text: "Ship fix" }), + ]), + }), + ]); }); it("preserves compaction trigger when completed update replaces loading marker", () => { @@ -1022,6 +1105,26 @@ describe("stream reducer canonical tool calls", () => { assert.strictEqual(todos.items[0]?.text, "Task 1"); }); + it.each(["TaskCreate", "TaskUpdate", "TaskList"])( + "suppresses Claude %s bookkeeping tool calls", + (name) => { + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "claude", + callId: name, + name, + status: "completed", + input: { taskId: "1", status: "completed" }, + }), + timestamp: new Date("2025-01-01T11:00:00Z"), + }, + ]); + + expect(state.filter(isAgentToolCallItem)).toEqual([]); + }, + ); + it("preserves submitted user message images when authoritative user message arrives", () => { const messageId = "msg-user-images"; const submittedTimestamp = new Date("2025-01-01T11:10:00Z"); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 2a2b41f5a..557279767 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -766,8 +766,15 @@ export interface CompactionItem { export interface TodoEntry { text: string; completed: boolean; + id?: string; + status?: "pending" | "in_progress" | "completed"; + activeForm?: string; } +export type TaskActivity = + | { type: "created"; count: number } + | { type: "added" | "started" | "completed" | "reopened"; task: string }; + export interface TodoListItem { kind: "todo_list"; id: string; @@ -775,6 +782,7 @@ export interface TodoListItem { timestamp: Date; provider: AgentProvider; items: TodoEntry[]; + activity: TaskActivity; } export type StreamUpdateSource = "live" | "canonical"; @@ -1178,34 +1186,103 @@ function appendTodoList( const normalizedItems = items.map((item) => ({ text: item.text, completed: item.completed, + ...(item.id ? { id: item.id } : {}), + ...(item.status ? { status: item.status } : {}), + ...(item.activeForm ? { activeForm: item.activeForm } : {}), })); - const lastItem = state[state.length - 1]; - if (lastItem && lastItem.kind === "todo_list" && lastItem.provider === provider) { + const previousIndex = state.findLastIndex( + (item) => item.kind === "todo_list" && item.provider === provider, + ); + const previous = state[previousIndex]; + const previousItems = previous?.kind === "todo_list" ? previous.items : []; + const activities = deriveTaskActivities(previousItems, normalizedItems); + + if (activities.length === 0) { + if (!previous || previous.kind !== "todo_list") return state; const next = [...state]; - const updated: TodoListItem = { - ...lastItem, + next[previousIndex] = { + ...previous, ...(timelineCursor ? { timelineCursor } : {}), items: normalizedItems, timestamp, }; - next[next.length - 1] = updated; return next; } - const idSeed = `${provider}:${JSON.stringify(normalizedItems)}`; - const entryId = createUniqueTimelineId(state, "todo", idSeed, timestamp); + const lastItem = state[state.length - 1]; + if ( + activities.length === 1 && + activities[0]?.type === "added" && + lastItem?.kind === "todo_list" && + lastItem.provider === provider && + lastItem.activity.type === "created" && + normalizedItems.every((item) => taskStatus(item) === "pending") + ) { + const next = [...state]; + next[next.length - 1] = { + ...lastItem, + ...(timelineCursor ? { timelineCursor } : {}), + items: normalizedItems, + activity: { type: "created", count: normalizedItems.length }, + timestamp, + }; + return next; + } - const entry: TodoListItem = { - kind: "todo_list", - id: entryId, - ...(timelineCursor ? { timelineCursor } : {}), - timestamp, - provider, - items: normalizedItems, - }; + const next = [...state]; + for (const activity of activities) { + const idSeed = `${provider}:${JSON.stringify(activity)}:${JSON.stringify(normalizedItems)}`; + next.push({ + kind: "todo_list", + id: createUniqueTimelineId(next, "todo", idSeed, timestamp), + ...(timelineCursor ? { timelineCursor } : {}), + timestamp, + provider, + items: normalizedItems, + activity, + }); + } + return next; +} - return [...state, entry]; +function taskStatus(task: TodoEntry): NonNullable { + if (task.completed || task.status === "completed") return "completed"; + return task.status === "in_progress" ? "in_progress" : "pending"; +} + +function taskKey(task: TodoEntry, index: number): string { + return task.id ?? `${index}:${task.text}`; +} + +function deriveTaskActivities( + previous: readonly TodoEntry[], + current: readonly TodoEntry[], +): TaskActivity[] { + if (previous.length === 0) { + return current.length > 0 ? [{ type: "created", count: current.length }] : []; + } + + const previousByKey = new Map(previous.map((task, index) => [taskKey(task, index), task])); + const activities: TaskActivity[] = []; + for (const [index, task] of current.entries()) { + const prior = previousByKey.get(taskKey(task, index)); + if (!prior) { + activities.push({ type: "added", task: task.text }); + continue; + } + const before = taskStatus(prior); + const after = taskStatus(task); + if (before === after) continue; + if (after === "completed") { + activities.push({ type: "completed", task: task.text }); + } else if (before === "completed") { + activities.push({ type: "reopened", task: task.text }); + } else if (after === "in_progress") { + activities.push({ type: "started", task: task.text }); + } + } + return activities; } function reduceTimelineToolCall( @@ -1243,6 +1320,15 @@ function reduceTimelineToolCall( ); } + if ( + event.provider === "claude" && + (normalizedToolName === "taskcreate" || + normalizedToolName === "taskupdate" || + normalizedToolName === "tasklist") + ) { + return state; + } + const tasks = extractTaskEntriesFromToolCall(item.name, inputFromUnknownDetail(item.detail)); if (tasks) { return appendTodoList( @@ -1349,12 +1435,12 @@ function reduceTimelineEvent( reduceTimelineToolCall(state, event, item, timestamp, timelineCursor), ); case "todo": { - if (event.provider === "claude") { - return finalizeActiveThoughts(state); - } const items: TodoEntry[] = (item.items ?? []).map((todo) => ({ text: todo.text, completed: todo.completed, + id: todo.id, + status: todo.status, + activeForm: todo.activeForm, })); return finalizeActiveThoughts( appendTodoList(state, event.provider, items, timestamp, timelineCursor), diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 9d2dffd2e..06d7fa72a 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -339,12 +339,20 @@ export interface CompactionTimelineItem { preTokens?: number; } +export interface AgentTaskItem { + text: string; + completed: boolean; + id?: string; + status?: "pending" | "in_progress" | "completed"; + activeForm?: string; +} + export type AgentTimelineItem = | { type: "user_message"; text: string; messageId?: string; clientMessageId?: string } | { type: "assistant_message"; text: string; messageId?: string } | { type: "reasoning"; text: string } | ToolCallTimelineItem - | { type: "todo"; items: { text: string; completed: boolean }[] } + | { type: "todo"; items: AgentTaskItem[] } | { type: "error"; message: string } | CompactionTimelineItem; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index bb95c66a3..7ec0a93ff 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -659,6 +659,9 @@ export const AgentTimelineItemPayloadSchema: z.ZodType { }); }); + test("task progress fields are optional on the wire", () => { + expect( + AgentTimelineItemPayloadSchema.parse({ + type: "todo", + items: [{ text: "Legacy task", completed: false }], + }), + ).toEqual({ type: "todo", items: [{ text: "Legacy task", completed: false }] }); + expect( + AgentTimelineItemPayloadSchema.parse({ + type: "todo", + items: [ + { + id: "task-1", + text: "Current task", + activeForm: "Working on current task", + status: "in_progress", + completed: false, + }, + ], + }), + ).toEqual({ + type: "todo", + items: [ + { + id: "task-1", + text: "Current task", + activeForm: "Working on current task", + status: "in_progress", + completed: false, + }, + ], + }); + }); + test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => { const parsed = LegacySubAgentToolCallSchema.parse({ type: "tool_call", diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index c2c585525..cea5a9f1b 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -1,12 +1,13 @@ import type { AgentProviderNotice, + AgentTaskItem, ProviderOptions, ToolPolicy, } from "@getpaseo/protocol/agent-types"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { PaseoToolCatalog } from "./tools/types.js"; -export type { AgentProviderNotice }; +export type { AgentProviderNotice, AgentTaskItem }; export type AgentProvider = string; @@ -383,7 +384,7 @@ export type AgentTimelineItem = | { type: "assistant_message"; text: string; messageId?: string } | { type: "reasoning"; text: string } | ToolCallTimelineItem - | { type: "todo"; items: { text: string; completed: boolean }[] } + | { type: "todo"; items: AgentTaskItem[] } | { type: "error"; message: string } | CompactionTimelineItem; diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 2abba3a6e..72652bee4 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -21,6 +21,7 @@ import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../ag interface TestClaudeSession { translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; + close(): Promise; } afterEach(() => { @@ -1388,6 +1389,52 @@ describe("ClaudeAgentSession context window usage", () => { }); } + test("emits canonical task snapshots from Claude TaskCreate results", async () => { + const session = await createSessionForTest(); + try { + session.translateMessageToEvents({ + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "task-create-1", + name: "TaskCreate", + input: { subject: "Inspect provider", activeForm: "Inspecting provider" }, + }, + ], + }, + } as unknown as SDKMessage); + + const events = session.translateMessageToEvents({ + type: "user", + message: { + content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }], + }, + toolUseResult: { task: { id: "1", subject: "Inspect provider" } }, + } as unknown as SDKMessage); + + expect(events).toContainEqual({ + type: "timeline", + provider: "claude", + item: { + type: "todo", + items: [ + { + id: "1", + text: "Inspect provider", + activeForm: "Inspecting provider", + status: "pending", + completed: false, + }, + ], + }, + }); + } finally { + await session.close(); + } + }); + async function collectStreamEvents(session: AgentSession, prompt = "turn") { const events: AgentStreamEvent[] = []; for await (const event of streamSession(session, prompt)) { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 96fa62aa8..fcd6b4694 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -44,6 +44,7 @@ import { } from "./model-manifest.js"; import { parsePartialJsonObject } from "./partial-json.js"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; +import { ClaudeTaskState } from "./task-state.js"; import { ClaudeTaskProtocolSource, type ClaudeHookObservationInput, @@ -2025,6 +2026,7 @@ class ClaudeAgentSession implements AgentSession { private autonomousTurn: AutonomousTurnState | null = null; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly timelineAssembler = new TimelineAssembler(); + private readonly taskState = new ClaudeTaskState(); private readonly taskProtocolSource = new ClaudeTaskProtocolSource({ getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null, readWorkflowResult: readClaudeWorkflowResultFile, @@ -2794,6 +2796,7 @@ class ClaudeAgentSession implements AgentSession { this.userMessageIds = []; this.emittedUserMessageIds.clear(); this.rewindTurnAnchors.length = 0; + this.taskState.reset(); this.loadPersistedHistory(sessionId); if (oldSessionId && oldSessionId !== sessionId) { this.dispatchEvents([ @@ -2824,6 +2827,7 @@ class ClaudeAgentSession implements AgentSession { this.userMessageIds = []; this.emittedUserMessageIds.clear(); this.rewindTurnAnchors.length = 0; + this.taskState.reset(); } private rememberUserMessageId(messageId: string | null | undefined): void { @@ -3837,6 +3841,7 @@ class ClaudeAgentSession implements AgentSession { } const events: AgentStreamEvent[] = []; + this.appendTaskStateEvent(message, events); // Subagent identity and lifecycle are announced by Claude Code's task protocol, so they are // read rather than inferred from sidechain frames. `task_started` precedes the child's first @@ -3901,6 +3906,11 @@ class ClaudeAgentSession implements AgentSession { return events; } + private appendTaskStateEvent(message: SDKMessage, events: AgentStreamEvent[]): void { + const item = this.taskState.observe(message); + if (item) events.push({ type: "timeline", provider: "claude", item }); + } + /** * A frame from inside a subagent, routed to that child rather than the parent's transcript. */ @@ -4559,6 +4569,7 @@ class ClaudeAgentSession implements AgentSession { private loadPersistedHistory(sessionId: string): void { try { + this.taskState.reset(); const historyPath = this.resolveHistoryPath(sessionId); if (!historyPath || !fs.existsSync(historyPath)) { return; @@ -4683,7 +4694,8 @@ class ClaudeAgentSession implements AgentSession { } const historyTimestamp = normalizeProviderReplayTimestamp(entry.timestamp); - const items = this.convertHistoryEntry(entry); + const taskSnapshot = this.taskState.observe(entry); + const items = [...(taskSnapshot ? [taskSnapshot] : []), ...this.convertHistoryEntry(entry)]; const isVisibleUserEntry = entry.type === "user" && typeof entry.uuid === "string" && diff --git a/packages/server/src/server/agent/providers/claude/task-state.test.ts b/packages/server/src/server/agent/providers/claude/task-state.test.ts new file mode 100644 index 000000000..b175cc58e --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/task-state.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "vitest"; +import { ClaudeTaskState } from "./task-state.js"; + +function toolUse(id: string, name: string, input: Record) { + return { type: "assistant", message: { content: [{ type: "tool_use", id, name, input }] } }; +} + +function toolResult(id: string, result: Record) { + return { + type: "user", + message: { content: [{ type: "tool_result", tool_use_id: id, content: "ok" }] }, + toolUseResult: result, + }; +} + +describe("ClaudeTaskState", () => { + test("accumulates TaskCreate and TaskUpdate mutations by task id", () => { + const state = new ClaudeTaskState(); + state.observe( + toolUse("create-1", "TaskCreate", { subject: "Alpha", activeForm: "Doing alpha" }), + ); + expect(state.observe(toolResult("create-1", { task: { id: "1", subject: "Alpha" } }))).toEqual({ + type: "todo", + items: [ + { id: "1", text: "Alpha", activeForm: "Doing alpha", status: "pending", completed: false }, + ], + }); + + state.observe(toolUse("update-1", "TaskUpdate", { taskId: "1", status: "in_progress" })); + expect(state.observe(toolResult("update-1", { success: true, taskId: "1" }))).toEqual({ + type: "todo", + items: [ + { + id: "1", + text: "Alpha", + activeForm: "Doing alpha", + status: "in_progress", + completed: false, + }, + ], + }); + }); + + test("removes deleted tasks and ignores replayed results", () => { + const state = new ClaudeTaskState(); + state.observe(toolUse("create", "TaskCreate", { subject: "Disposable" })); + state.observe(toolResult("create", { task: { id: "1", subject: "Disposable" } })); + state.observe(toolUse("delete", "TaskUpdate", { taskId: "1", status: "deleted" })); + const result = toolResult("delete", { success: true, taskId: "1" }); + expect(state.observe(result)).toEqual({ type: "todo", items: [] }); + expect(state.observe(result)).toBeNull(); + }); + + test("preserves status when TaskUpdate changes only descriptive fields", () => { + const state = new ClaudeTaskState(); + state.observe(toolUse("create", "TaskCreate", { subject: "Original" })); + state.observe(toolResult("create", { task: { id: "1", subject: "Original" } })); + state.observe(toolUse("complete", "TaskUpdate", { taskId: "1", status: "completed" })); + state.observe(toolResult("complete", { success: true, taskId: "1" })); + state.observe(toolUse("rename", "TaskUpdate", { taskId: "1", subject: "Renamed" })); + + expect(state.observe(toolResult("rename", { success: true, taskId: "1" }))).toEqual({ + type: "todo", + items: [{ id: "1", text: "Renamed", status: "completed", completed: true }], + }); + }); + + test("replaces state from TodoWrite and TaskList snapshots", () => { + const state = new ClaudeTaskState(); + expect( + state.observe( + toolUse("legacy", "TodoWrite", { + todos: [{ content: "Legacy", status: "in_progress", activeForm: "Working" }], + }), + ), + ).toEqual({ + type: "todo", + items: [ + { + id: "legacy:0", + text: "Legacy", + activeForm: "Working", + status: "in_progress", + completed: false, + }, + ], + }); + state.observe(toolUse("list", "TaskList", {})); + expect( + state.observe( + toolResult("list", { + tasks: [{ id: "7", subject: "Current", status: "completed", activeForm: "Finishing" }], + }), + ), + ).toEqual({ + type: "todo", + items: [ + { id: "7", text: "Current", activeForm: "Finishing", status: "completed", completed: true }, + ], + }); + }); + + test("keeps synthetic TodoWrite task ids stable across snapshots", () => { + const state = new ClaudeTaskState(); + state.observe( + toolUse("first", "TodoWrite", { + todos: [{ content: "Stable", status: "pending" }], + }), + ); + + expect( + state.observe( + toolUse("second", "TodoWrite", { + todos: [{ content: "Stable", status: "completed" }], + }), + ), + ).toEqual({ + type: "todo", + items: [{ id: "legacy:0", text: "Stable", status: "completed", completed: true }], + }); + }); + + test("does not confuse Claude's subagent Task tool with task tracking", () => { + const state = new ClaudeTaskState(); + expect(state.observe(toolUse("subagent", "Task", { description: "delegate" }))).toBeNull(); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/task-state.ts b/packages/server/src/server/agent/providers/claude/task-state.ts new file mode 100644 index 000000000..bcb4df26b --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/task-state.ts @@ -0,0 +1,202 @@ +import type { AgentTaskItem, AgentTimelineItem } from "../../agent-sdk-types.js"; + +type TaskToolName = "TodoWrite" | "TaskCreate" | "TaskUpdate" | "TaskList"; + +interface PendingTaskTool { + name: TaskToolName; + input: Record; +} + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function string(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function taskStatus(value: unknown): AgentTaskItem["status"] | "deleted" { + if (value === "completed" || value === "deleted" || value === "in_progress") return value; + return "pending"; +} + +function retainedTaskStatus(task: AgentTaskItem): AgentTaskItem["status"] { + if (task.status) return task.status; + return task.completed ? "completed" : "pending"; +} + +function toTaskItem(value: unknown): AgentTaskItem | null { + const task = record(value); + if (!task) return null; + const text = string(task.subject) ?? string(task.content) ?? string(task.text); + if (!text) return null; + const status = taskStatus(task.status); + if (status === "deleted") return null; + const id = string(task.id) ?? string(task.taskId); + const activeForm = string(task.activeForm) ?? string(task.active_form); + return { + ...(id ? { id } : {}), + text, + status, + completed: status === "completed", + ...(activeForm ? { activeForm } : {}), + }; +} + +function toolUses(message: Record): Array> { + const content = record(message.message)?.content; + return Array.isArray(content) + ? content.flatMap((block) => { + const candidate = record(block); + return candidate?.type === "tool_use" ? [candidate] : []; + }) + : []; +} + +function toolResultId(message: Record): string | undefined { + const content = record(message.message)?.content; + if (!Array.isArray(content)) return undefined; + for (const block of content) { + const candidate = record(block); + if (candidate?.type === "tool_result") return string(candidate.tool_use_id); + } + return undefined; +} + +function structuredResult(message: Record): Record | null { + return record(message.toolUseResult) ?? record(message.tool_use_result); +} + +/** Accumulates Claude's snapshot and ID-based task tools into canonical todo snapshots. */ +export class ClaudeTaskState { + private readonly tasks = new Map(); + private readonly calls = new Map(); + private readonly appliedResults = new Set(); + + observe(value: unknown): Extract | null { + const message = record(value); + if (!message) return null; + + let snapshot: Extract | null = null; + for (const block of toolUses(message)) { + const id = string(block.id); + const name = string(block.name); + if (!id || !isTaskToolName(name)) continue; + const input = record(block.input) ?? {}; + this.calls.set(id, { name, input }); + if (name === "TodoWrite") snapshot = this.replaceLegacyTodos(input.todos); + } + + const resultId = toolResultId(message); + if (!resultId || this.appliedResults.has(resultId)) return snapshot; + const call = this.calls.get(resultId); + if (!call) return snapshot; + this.appliedResults.add(resultId); + this.calls.delete(resultId); + return this.applyResult(call, structuredResult(message)) ?? snapshot; + } + + reset(): void { + this.tasks.clear(); + this.calls.clear(); + this.appliedResults.clear(); + } + + private replaceLegacyTodos(value: unknown): Extract { + this.tasks.clear(); + if (Array.isArray(value)) { + for (const [index, taskValue] of value.entries()) { + const item = toTaskItem(taskValue); + if (!item) continue; + const id = item.id ?? `legacy:${index}`; + this.tasks.set(id, { ...item, id }); + } + } + return this.snapshot(); + } + + private applyResult( + call: PendingTaskTool, + result: Record | null, + ): Extract | null { + if (result?.success === false) return null; + if (call.name === "TaskCreate") return this.applyCreate(call.input, result); + if (call.name === "TaskUpdate") return this.applyUpdate(call.input, result); + if (call.name === "TaskList") return this.applyList(result); + return null; + } + + private applyCreate( + input: Record, + result: Record | null, + ): Extract | null { + const resultTask = record(result?.task); + const id = string(resultTask?.id) ?? string(result?.taskId); + const text = string(resultTask?.subject) ?? string(input.subject); + if (!id || !text) return null; + const activeForm = string(input.activeForm); + this.tasks.set(id, { + id, + text, + status: "pending", + completed: false, + ...(activeForm ? { activeForm } : {}), + }); + return this.snapshot(); + } + + private applyUpdate( + input: Record, + result: Record | null, + ): Extract | null { + const id = string(input.taskId) ?? string(result?.taskId); + if (!id) return null; + const current = this.tasks.get(id); + if (!current) return null; + const statusValue = input.status ?? record(result?.statusChange)?.to; + const status = + statusValue === undefined ? retainedTaskStatus(current) : taskStatus(statusValue); + if (status === "deleted") { + this.tasks.delete(id); + return this.snapshot(); + } + const text = string(input.subject); + const activeForm = string(input.activeForm); + this.tasks.set(id, { + ...current, + ...(text ? { text } : {}), + ...(activeForm ? { activeForm } : {}), + status, + completed: status === "completed", + }); + return this.snapshot(); + } + + private applyList( + result: Record | null, + ): Extract | null { + const tasks = result?.tasks; + if (!Array.isArray(tasks)) return null; + this.tasks.clear(); + for (const taskValue of tasks) { + const item = toTaskItem(taskValue); + if (item?.id) this.tasks.set(item.id, item); + } + return this.snapshot(); + } + + private snapshot(): Extract { + return { type: "todo", items: [...this.tasks.values()] }; + } +} + +function isTaskToolName(value: string | undefined): value is TaskToolName { + return ( + value === "TodoWrite" || + value === "TaskCreate" || + value === "TaskUpdate" || + value === "TaskList" + ); +} diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index be08a231c..c0d16c70c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -22,10 +22,30 @@ import { codexAppServerTurnInputFromPrompt, listCodexSkills, mapCodexPatchNotificationToToolCall, + mapCodexPlanUpdateToTodo, mapCodexPlanToToolCall, normalizeCodexOutputSchema, toAgentUsage, } from "./codex-app-server-agent.js"; + +describe("mapCodexPlanUpdateToTodo", () => { + test("preserves checklist progress without creating a plan card", () => { + expect( + mapCodexPlanUpdateToTodo([ + { step: "Inspect", status: "completed" }, + { step: "Implement", status: "inProgress" }, + { step: "Verify", status: "pending" }, + ]), + ).toEqual({ + type: "todo", + items: [ + { id: "0", text: "Inspect", status: "completed", completed: true }, + { id: "1", text: "Implement", status: "in_progress", completed: false }, + { id: "2", text: "Verify", status: "pending", completed: false }, + ], + }); + }); +}); import { CodexAppServerClient } from "./codex/app-server-transport.js"; import { createFakeCodexAppServer, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 98f5aa843..ae119ef89 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -939,6 +939,26 @@ export function planStepsToMarkdown(steps: Array<{ step: string; status: string return normalizePlanMarkdown(lines.join("\n")); } +export function mapCodexPlanUpdateToTodo( + steps: Array<{ step?: string | null; status?: string | null }>, +): Extract { + return { + type: "todo", + items: steps.flatMap((entry, index) => { + const text = entry.step?.trim(); + if (!text) return []; + const status = normalizeCodexTaskStatus(entry.status); + return [{ id: String(index), text, status, completed: status === "completed" }]; + }), + }; +} + +function normalizeCodexTaskStatus(status: string | null | undefined) { + if (status === "completed") return "completed" as const; + if (status === "inProgress" || status === "in_progress") return "in_progress" as const; + return "pending" as const; +} + export function mapCodexPlanToToolCall(params: { callId: string; text: string; @@ -5597,6 +5617,14 @@ export class CodexAppServerAgentSession implements AgentSession { private handlePlanUpdatedNotification( parsed: Extract, ): void { + if (!this.planModeEnabled) { + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: mapCodexPlanUpdateToTodo(parsed.plan), + }); + return; + } const timelineItem = mapCodexPlanToToolCall({ callId: `plan:${this.currentTurnId ?? this.currentThreadId ?? "current"}`, text: planStepsToMarkdown( @@ -5608,13 +5636,9 @@ export class CodexAppServerAgentSession implements AgentSession { }); if (timelineItem) { this.rememberPlanResult(timelineItem); - // In plan mode, the same plan is rendered through the synthetic approval - // permission. Keep the remembered text for that card, but do not also - // emit a static timeline plan panel. - if (this.planModeEnabled) { - return; - } - this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + // Older Codex app-server builds reported Plan-mode proposals through + // turn/plan/updated. Retain that compatibility path only while Plan mode is active. + return; } } diff --git a/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts b/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts index b427e5ba6..e04ddee2b 100644 --- a/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts @@ -15,7 +15,7 @@ const TODO_PHASES = [ ] as const; describe("OMP todo mapper", () => { - test("maps todo tool results and collapses statuses to completed booleans", () => { + test("maps todo tool results without losing progress status", () => { expect( mapOmpTodoToolResult( parseToolResult({ @@ -37,9 +37,9 @@ describe("OMP todo mapper", () => { ).toEqual({ type: "todo", items: [ - { text: "alpha task", completed: false }, - { text: "beta task", completed: false }, - { text: "gamma task", completed: false }, + { text: "alpha task", status: "in_progress", completed: false }, + { text: "beta task", status: "pending", completed: false }, + { text: "gamma task", status: "pending", completed: false }, ], }); @@ -48,9 +48,9 @@ describe("OMP todo mapper", () => { ).toEqual({ type: "todo", items: [ - { text: "alpha task", completed: true }, - { text: "beta task", completed: false }, - { text: "gamma task", completed: false }, + { text: "alpha task", status: "completed", completed: true }, + { text: "beta task", status: "in_progress", completed: false }, + { text: "gamma task", status: "pending", completed: false }, ], }); }); @@ -67,8 +67,8 @@ describe("OMP todo mapper", () => { ).toEqual({ type: "todo", items: [ - { text: "beta task", completed: false }, - { text: "gamma task", completed: false }, + { text: "beta task", status: "in_progress", completed: false }, + { text: "gamma task", status: "pending", completed: false }, ], }); }); @@ -89,9 +89,9 @@ describe("OMP todo mapper", () => { { type: "todo", items: [ - { text: "alpha task", completed: true }, - { text: "beta task", completed: false }, - { text: "gamma task", completed: false }, + { text: "alpha task", status: "completed", completed: true }, + { text: "beta task", status: "in_progress", completed: false }, + { text: "gamma task", status: "pending", completed: false }, ], }, ]); diff --git a/packages/server/src/server/agent/providers/omp/todo-mapper.ts b/packages/server/src/server/agent/providers/omp/todo-mapper.ts index 1d8af9083..0b41d5a43 100644 --- a/packages/server/src/server/agent/providers/omp/todo-mapper.ts +++ b/packages/server/src/server/agent/providers/omp/todo-mapper.ts @@ -41,11 +41,18 @@ function mapOmpTodoItems(items: readonly OmpTodoItem[]): AgentTimelineItem | nul type: "todo", items: items.map((item) => ({ text: item.content, + status: normalizeOmpTodoStatus(item.status), completed: item.status === "completed", })), }; } +function normalizeOmpTodoStatus(status: OmpTodoItem["status"]) { + if (status === "completed") return "completed" as const; + if (status === "in_progress") return "in_progress" as const; + return "pending" as const; +} + function resultDetails(result: OmpToolResult): Record | null { if (typeof result === "string" || result === null) { return null; diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 2c31762e3..3a480f7b9 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -1878,7 +1878,13 @@ describe("OpenCode adapter startTurn error handling", () => { provider: "opencode", item: { type: "todo", - items: [{ text: "Inspect current directory and existing files", completed: true }], + items: [ + { + text: "Inspect current directory and existing files", + status: "completed", + completed: true, + }, + ], }, }, ]); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 6711f556a..3ea0baab7 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1905,6 +1905,7 @@ function mapOpenCodeTodosToTimelineItems( return [ { text, + status: normalizeOpenCodeTodoStatus(todo.status), completed: todo.status === "completed", }, ]; @@ -1912,6 +1913,12 @@ function mapOpenCodeTodosToTimelineItems( }; } +function normalizeOpenCodeTodoStatus(status?: string | null) { + if (status === "completed") return "completed" as const; + if (status === "in_progress" || status === "inProgress") return "in_progress" as const; + return "pending" as const; +} + function createCompactionTimelineItem( status: Extract["status"], trigger?: Extract["trigger"], diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index e8f7f5d94..2c5d6792c 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -680,8 +680,8 @@ describe("translateOpenCodeEvent", () => { item: { type: "todo", items: [ - { text: "Outline", completed: false }, - { text: "Ship", completed: true }, + { text: "Outline", status: "pending", completed: false }, + { text: "Ship", status: "completed", completed: true }, ], }, },