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
This commit is contained in:
Mohamed Boudra
2026-08-11 18:23:30 +02:00
committed by GitHub
parent b2ff359b60
commit db6520bf36
37 changed files with 1342 additions and 60 deletions
+1 -1
View File
@@ -799,7 +799,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
case "todo_list":
return <TodoListCard items={item.items} />;
return <TodoListCard items={item.items} activity={item.activity} />;
case "compaction":
return (
+53 -9
View File
@@ -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 (
<View style={todoListCardStylesheet.itemRow}>
<View style={badgeStyle}>
<View
style={[badgeStyle, status === "in_progress" && todoListCardStylesheet.radioBadgeActive]}
>
{completed ? (
<ThemedTodoCheckIcon size={12} uniProps={primaryForegroundColorMapping} />
) : 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({
<Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text>
) : (
items.map((item) => (
<TodoListItemRow key={item.text} text={item.text} completed={item.completed} />
<TodoListItemRow
key={item.id ?? item.text}
text={item.text}
completed={item.completed}
status={item.status}
/>
))
)}
</View>
@@ -2346,9 +2390,9 @@ export const TodoListCard = memo(function TodoListCard({
return (
<ExpandableBadge
label={t("message.todo.title")}
secondaryLabel={nextTask}
icon={CheckSquare}
label={activityDisplay.label}
secondaryLabel={activityDisplay.secondaryLabel}
icon={taskActivityIcon(activity)}
isExpanded={isExpanded}
onToggle={handleToggle}
renderDetails={renderDetails}
@@ -0,0 +1,163 @@
import { memo, useCallback, useMemo, useState } from "react";
import { Text, View } from "react-native";
import { Check, ChevronDown, ChevronRight, Circle, CircleDot } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { useSessionStore } from "@/stores/session-store";
import type { Theme } from "@/styles/theme";
import type { TodoEntry } from "@/types/stream";
const ThemedCheck = withUnistyles(Check);
const ThemedCircle = withUnistyles(Circle);
const ThemedCircleDot = withUnistyles(CircleDot);
const mutedIcon = (theme: Theme) => ({ 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 <TaskListCard tasks={tasks} />;
});
function TaskStatusIcon({ task }: { task: TodoEntry }) {
if (task.completed || task.status === "completed") {
return <ThemedCheck size={15} uniProps={completedIcon} />;
}
if (task.status === "in_progress") {
return <ThemedCircleDot size={15} uniProps={activeIcon} />;
}
return <ThemedCircle size={15} uniProps={mutedIcon} />;
}
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 (
<View style={styles.container} accessibilityLabel={t("message.todo.title")}>
<View style={styles.track}>
<View style={styles.card}>
<Button
variant="ghost"
size="xs"
onPress={toggle}
accessibilityLabel={label}
accessibilityState={accessibilityState}
leftIcon={expanded ? ChevronDown : ChevronRight}
style={styles.header}
textStyle={styles.headerText}
>
{label}
</Button>
{expanded ? (
<View style={styles.list}>
{tasks.map((task, index) => {
const isActive = task.status === "in_progress";
const text = isActive && task.activeForm ? task.activeForm : task.text;
return (
<View
key={task.id ?? `${index}:${task.text}`}
style={styles.row}
accessibilityLabel={text}
>
<TaskStatusIcon task={task} />
<Text
numberOfLines={1}
style={[styles.taskText, task.completed && styles.completedText]}
>
{text}
</Text>
</View>
);
})}
</View>
) : null}
</View>
</View>
</View>
);
});
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",
},
}));
+8
View File
@@ -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", () => {
+9
View File
@@ -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: "الضغط...",
+9
View File
@@ -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...",
+9
View File
@@ -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...",
+9
View File
@@ -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...",
+9
View File
@@ -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: "コンテキストを圧縮中...",
+9
View File
@@ -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: "압축하는 중...",
+9
View File
@@ -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...",
+9
View File
@@ -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: "Уплотнение...",
+9
View File
@@ -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: "正在压缩...",
+2
View File
@@ -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 (
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
<AgentTaskList serverId={serverId} agentId={agentId} />
<SubagentsTrack
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
@@ -80,6 +80,62 @@ function permutations<T>(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();
+37 -1
View File
@@ -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<string, TodoEntry[]>,
agentId: string,
taskSnapshot: TodoEntry[] | undefined,
): Map<string, TodoEntry[]> {
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<string, StreamItem[]>;
agentStreamHead: Map<string, StreamItem[]>;
agentTasks: Map<string, TodoEntry[]>;
agentTurnLiveness: Map<string, TurnLiveness>;
messageSubmissions: Map<string, MessageSubmissionRecord[]>;
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
@@ -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<SessionStore>()(
const session = createInitialSessionState(serverId, null);
const timeline = replica.timeline;
const agentStreamTail = new Map<string, StreamItem[]>();
const agentTasks = new Map<string, TodoEntry[]>();
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<SessionStore>()(
workspaces: replica.workspaces,
projects: replica.projects,
agentStreamTail,
agentTasks,
},
},
agentLastActivity,
@@ -1149,8 +1177,10 @@ export const useSessionStore = create<SessionStore>()(
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<SessionStore>()(
...session,
agentStreamTail: nextTail,
agentStreamHead: nextHead,
agentTasks,
messageSubmissions,
},
},
@@ -1601,6 +1632,10 @@ export const useSessionStore = create<SessionStore>()(
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<SessionStore>()(
...session,
agentStreamTail: nextTail,
agentStreamHead: nextHead,
agentTasks,
agentTimelineCursor: nextCursor,
agentTimelineHasOlder: nextHasOlder,
agentTimelineHasNewer: nextHasNewer,
@@ -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",
@@ -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<string>();
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) {
+105 -2
View File
@@ -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");
+105 -19
View File
@@ -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<TodoEntry["status"]> {
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),