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:
@@ -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<typeof launchAgent>[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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export async function openCompletedTaskList(page: Page, count: number): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
for (const entry of entries) {
|
||||||
|
await expect(page.getByLabel(entry, { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectCompletedTaskActivity(
|
||||||
|
page: Page,
|
||||||
|
entries: readonly string[],
|
||||||
|
): Promise<void> {
|
||||||
|
for (const entry of entries) {
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: `Completed ${entry}`, exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -799,7 +799,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
|
|
||||||
case "todo_list":
|
case "todo_list":
|
||||||
return <TodoListCard items={item.items} />;
|
return <TodoListCard items={item.items} activity={item.activity} />;
|
||||||
|
|
||||||
case "compaction":
|
case "compaction":
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Check,
|
Check,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
|
CircleDot,
|
||||||
Copy,
|
Copy,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
Scissors,
|
Scissors,
|
||||||
MicVocal,
|
MicVocal,
|
||||||
@@ -62,7 +65,7 @@ import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "reac
|
|||||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer";
|
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 { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||||
@@ -2240,15 +2243,32 @@ export const CompactionMarker = memo(function CompactionMarker({
|
|||||||
|
|
||||||
interface TodoListCardProps {
|
interface TodoListCardProps {
|
||||||
items: TodoEntry[];
|
items: TodoEntry[];
|
||||||
|
activity: TaskActivity;
|
||||||
disableOuterSpacing?: boolean;
|
disableOuterSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TodoListItemRowProps {
|
interface TodoListItemRowProps {
|
||||||
text: string;
|
text: string;
|
||||||
completed: boolean;
|
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(
|
const badgeStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
todoListCardStylesheet.radioBadge,
|
todoListCardStylesheet.radioBadge,
|
||||||
@@ -2264,7 +2284,9 @@ function TodoListItemRow({ text, completed }: TodoListItemRowProps) {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View style={todoListCardStylesheet.itemRow}>
|
<View style={todoListCardStylesheet.itemRow}>
|
||||||
<View style={badgeStyle}>
|
<View
|
||||||
|
style={[badgeStyle, status === "in_progress" && todoListCardStylesheet.radioBadgeActive]}
|
||||||
|
>
|
||||||
{completed ? (
|
{completed ? (
|
||||||
<ThemedTodoCheckIcon size={12} uniProps={primaryForegroundColorMapping} />
|
<ThemedTodoCheckIcon size={12} uniProps={primaryForegroundColorMapping} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -2300,6 +2322,12 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
|||||||
radioBadgeComplete: {
|
radioBadgeComplete: {
|
||||||
opacity: 0.95,
|
opacity: 0.95,
|
||||||
},
|
},
|
||||||
|
radioBadgeActive: {
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: theme.colors.primary,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
itemText: {
|
itemText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -2317,12 +2345,23 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
|||||||
|
|
||||||
export const TodoListCard = memo(function TodoListCard({
|
export const TodoListCard = memo(function TodoListCard({
|
||||||
items,
|
items,
|
||||||
|
activity,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
}: TodoListCardProps) {
|
}: TodoListCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const activityDisplay = useMemo(() => {
|
||||||
const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]);
|
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(() => {
|
const handleToggle = useCallback(() => {
|
||||||
setIsExpanded((prev) => !prev);
|
setIsExpanded((prev) => !prev);
|
||||||
@@ -2336,7 +2375,12 @@ export const TodoListCard = memo(function TodoListCard({
|
|||||||
<Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text>
|
<Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text>
|
||||||
) : (
|
) : (
|
||||||
items.map((item) => (
|
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>
|
</View>
|
||||||
@@ -2346,9 +2390,9 @@ export const TodoListCard = memo(function TodoListCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ExpandableBadge
|
<ExpandableBadge
|
||||||
label={t("message.todo.title")}
|
label={activityDisplay.label}
|
||||||
secondaryLabel={nextTask}
|
secondaryLabel={activityDisplay.secondaryLabel}
|
||||||
icon={CheckSquare}
|
icon={taskActivityIcon(activity)}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
renderDetails={renderDetails}
|
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",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -393,6 +393,14 @@ describe("translation resources", () => {
|
|||||||
expect(en.message.question.otherPlaceholder).toBe("Other...");
|
expect(en.message.question.otherPlaceholder).toBe("Other...");
|
||||||
expect(en.message.todo.title).toBe("Tasks");
|
expect(en.message.todo.title).toBe("Tasks");
|
||||||
expect(en.message.todo.empty).toBe("No tasks yet.");
|
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", () => {
|
it("includes workspace tab toast keys for the Batch 4J migration", () => {
|
||||||
|
|||||||
@@ -333,6 +333,15 @@ export const ar: TranslationResources = {
|
|||||||
todo: {
|
todo: {
|
||||||
title: "المهام",
|
title: "المهام",
|
||||||
empty: "لا توجد مهام حتى الآن.",
|
empty: "لا توجد مهام حتى الآن.",
|
||||||
|
tasksProgress: "{{completed}}/{{total}} مهام",
|
||||||
|
tasksProgressCurrent: "{{completed}}/{{total}} مهام · {{task}}",
|
||||||
|
activity: {
|
||||||
|
created: "تم إنشاء {{count}} مهام",
|
||||||
|
added: "أُضيفت",
|
||||||
|
started: "بدأت",
|
||||||
|
completed: "اكتملت",
|
||||||
|
reopened: "أُعيد فتحها",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
compaction: {
|
compaction: {
|
||||||
loading: "الضغط...",
|
loading: "الضغط...",
|
||||||
|
|||||||
@@ -332,6 +332,15 @@ export const en = {
|
|||||||
todo: {
|
todo: {
|
||||||
title: "Tasks",
|
title: "Tasks",
|
||||||
empty: "No tasks yet.",
|
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: {
|
compaction: {
|
||||||
loading: "Compacting...",
|
loading: "Compacting...",
|
||||||
|
|||||||
@@ -336,6 +336,15 @@ export const es: TranslationResources = {
|
|||||||
todo: {
|
todo: {
|
||||||
title: "Tareas",
|
title: "Tareas",
|
||||||
empty: "Aún no hay 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: {
|
compaction: {
|
||||||
loading: "Compactando...",
|
loading: "Compactando...",
|
||||||
|
|||||||
@@ -337,6 +337,15 @@ export const fr: TranslationResources = {
|
|||||||
todo: {
|
todo: {
|
||||||
title: "Tâches",
|
title: "Tâches",
|
||||||
empty: "Aucune tâche pour l'instant.",
|
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: {
|
compaction: {
|
||||||
loading: "Compactage...",
|
loading: "Compactage...",
|
||||||
|
|||||||
@@ -336,6 +336,15 @@ export const ja: TranslationResources = {
|
|||||||
todo: {
|
todo: {
|
||||||
title: "タスク",
|
title: "タスク",
|
||||||
empty: "タスクがまだありません。",
|
empty: "タスクがまだありません。",
|
||||||
|
tasksProgress: "{{completed}}/{{total}}タスク",
|
||||||
|
tasksProgressCurrent: "{{completed}}/{{total}}タスク · {{task}}",
|
||||||
|
activity: {
|
||||||
|
created: "{{count}}件のタスクを作成",
|
||||||
|
added: "追加",
|
||||||
|
started: "開始",
|
||||||
|
completed: "完了",
|
||||||
|
reopened: "再開",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
compaction: {
|
compaction: {
|
||||||
loading: "コンテキストを圧縮中...",
|
loading: "コンテキストを圧縮中...",
|
||||||
|
|||||||
@@ -334,6 +334,15 @@ export const ko: TranslationResources = {
|
|||||||
todo: {
|
todo: {
|
||||||
title: "작업",
|
title: "작업",
|
||||||
empty: "아직 작업이 없습니다.",
|
empty: "아직 작업이 없습니다.",
|
||||||
|
tasksProgress: "작업 {{completed}}/{{total}}개",
|
||||||
|
tasksProgressCurrent: "작업 {{completed}}/{{total}}개 · {{task}}",
|
||||||
|
activity: {
|
||||||
|
created: "작업 {{count}}개 생성",
|
||||||
|
added: "추가됨",
|
||||||
|
started: "시작됨",
|
||||||
|
completed: "완료됨",
|
||||||
|
reopened: "다시 열림",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
compaction: {
|
compaction: {
|
||||||
loading: "압축하는 중...",
|
loading: "압축하는 중...",
|
||||||
|
|||||||
@@ -336,6 +336,15 @@ export const ptBR: TranslationResources = {
|
|||||||
todo: {
|
todo: {
|
||||||
title: "Tarefas",
|
title: "Tarefas",
|
||||||
empty: "Nenhuma tarefa ainda.",
|
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: {
|
compaction: {
|
||||||
loading: "Compactando...",
|
loading: "Compactando...",
|
||||||
|
|||||||
@@ -335,6 +335,15 @@ export const ru: TranslationResources = {
|
|||||||
todo: {
|
todo: {
|
||||||
title: "Задачи",
|
title: "Задачи",
|
||||||
empty: "Заданий пока нет.",
|
empty: "Заданий пока нет.",
|
||||||
|
tasksProgress: "{{completed}}/{{total}} задач",
|
||||||
|
tasksProgressCurrent: "{{completed}}/{{total}} задач · {{task}}",
|
||||||
|
activity: {
|
||||||
|
created: "Создано задач: {{count}}",
|
||||||
|
added: "Добавлена",
|
||||||
|
started: "Начата",
|
||||||
|
completed: "Завершена",
|
||||||
|
reopened: "Возобновлена",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
compaction: {
|
compaction: {
|
||||||
loading: "Уплотнение...",
|
loading: "Уплотнение...",
|
||||||
|
|||||||
@@ -333,6 +333,15 @@ export const zhCN: TranslationResources = {
|
|||||||
todo: {
|
todo: {
|
||||||
title: "任务",
|
title: "任务",
|
||||||
empty: "还没有任务。",
|
empty: "还没有任务。",
|
||||||
|
tasksProgress: "{{completed}}/{{total}} 项任务",
|
||||||
|
tasksProgressCurrent: "{{completed}}/{{total}} 项任务 · {{task}}",
|
||||||
|
activity: {
|
||||||
|
created: "已创建 {{count}} 项任务",
|
||||||
|
added: "已添加",
|
||||||
|
started: "已开始",
|
||||||
|
completed: "已完成",
|
||||||
|
reopened: "已重新打开",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
compaction: {
|
compaction: {
|
||||||
loading: "正在压缩...",
|
loading: "正在压缩...",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import {
|
|||||||
deriveRouteBottomAnchorRequest,
|
deriveRouteBottomAnchorRequest,
|
||||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||||
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
||||||
|
import { AgentTaskList } from "@/composer/task-list";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
@@ -1612,6 +1613,7 @@ function ActiveAgentComposer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
|
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
|
||||||
|
<AgentTaskList serverId={serverId} agentId={agentId} />
|
||||||
<SubagentsTrack
|
<SubagentsTrack
|
||||||
rows={subagentRows}
|
rows={subagentRows}
|
||||||
onOpenSubagent={handleOpenSubagent}
|
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", () => {
|
describe("agent timeline state", () => {
|
||||||
it("commits canonical items, range, and older availability as one synced state", () => {
|
it("commits canonical items, range, and older availability as one synced state", () => {
|
||||||
initializeTestSession();
|
initializeTestSession();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
handoffCreatedAgentUserMessageToStream,
|
handoffCreatedAgentUserMessageToStream,
|
||||||
removeSubmittedUserMessage,
|
removeSubmittedUserMessage,
|
||||||
type StreamItem,
|
type StreamItem,
|
||||||
|
type TodoEntry,
|
||||||
type UserMessageItem,
|
type UserMessageItem,
|
||||||
} from "@/types/stream";
|
} from "@/types/stream";
|
||||||
import {
|
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";
|
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
|
||||||
|
|
||||||
// Per-session state
|
// Per-session state
|
||||||
@@ -426,6 +447,7 @@ export interface SessionState {
|
|||||||
// Stream state (head/tail model)
|
// Stream state (head/tail model)
|
||||||
agentStreamTail: Map<string, StreamItem[]>;
|
agentStreamTail: Map<string, StreamItem[]>;
|
||||||
agentStreamHead: Map<string, StreamItem[]>;
|
agentStreamHead: Map<string, StreamItem[]>;
|
||||||
|
agentTasks: Map<string, TodoEntry[]>;
|
||||||
agentTurnLiveness: Map<string, TurnLiveness>;
|
agentTurnLiveness: Map<string, TurnLiveness>;
|
||||||
messageSubmissions: Map<string, MessageSubmissionRecord[]>;
|
messageSubmissions: Map<string, MessageSubmissionRecord[]>;
|
||||||
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
||||||
@@ -523,6 +545,7 @@ interface SessionStoreActions {
|
|||||||
tail?: StreamItem[];
|
tail?: StreamItem[];
|
||||||
head?: StreamItem[];
|
head?: StreamItem[];
|
||||||
acknowledgedClientMessageIds?: readonly string[];
|
acknowledgedClientMessageIds?: readonly string[];
|
||||||
|
taskSnapshot?: TodoEntry[];
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
applyAgentTurnLiveness: (
|
applyAgentTurnLiveness: (
|
||||||
@@ -692,6 +715,7 @@ function createInitialSessionState(
|
|||||||
currentAssistantMessage: "",
|
currentAssistantMessage: "",
|
||||||
agentStreamTail: new Map(),
|
agentStreamTail: new Map(),
|
||||||
agentStreamHead: new Map(),
|
agentStreamHead: new Map(),
|
||||||
|
agentTasks: new Map(),
|
||||||
agentTurnLiveness: new Map(),
|
agentTurnLiveness: new Map(),
|
||||||
messageSubmissions: new Map(),
|
messageSubmissions: new Map(),
|
||||||
agentTimelineCursor: new Map(),
|
agentTimelineCursor: new Map(),
|
||||||
@@ -812,8 +836,11 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
const session = createInitialSessionState(serverId, null);
|
const session = createInitialSessionState(serverId, null);
|
||||||
const timeline = replica.timeline;
|
const timeline = replica.timeline;
|
||||||
const agentStreamTail = new Map<string, StreamItem[]>();
|
const agentStreamTail = new Map<string, StreamItem[]>();
|
||||||
|
const agentTasks = new Map<string, TodoEntry[]>();
|
||||||
if (timeline) {
|
if (timeline) {
|
||||||
agentStreamTail.set(timeline.agentId, timeline.items);
|
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);
|
const agentLastActivity = new Map(prev.agentLastActivity);
|
||||||
for (const agent of replica.agents.values()) {
|
for (const agent of replica.agents.values()) {
|
||||||
@@ -830,6 +857,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
workspaces: replica.workspaces,
|
workspaces: replica.workspaces,
|
||||||
projects: replica.projects,
|
projects: replica.projects,
|
||||||
agentStreamTail,
|
agentStreamTail,
|
||||||
|
agentTasks,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
agentLastActivity,
|
agentLastActivity,
|
||||||
@@ -1149,8 +1177,10 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
state.acknowledgedClientMessageIds ?? [],
|
state.acknowledgedClientMessageIds ?? [],
|
||||||
);
|
);
|
||||||
const changedSubmissions = observedSubmissions !== currentSubmissions;
|
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;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,6 +1202,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
...session,
|
...session,
|
||||||
agentStreamTail: nextTail,
|
agentStreamTail: nextTail,
|
||||||
agentStreamHead: nextHead,
|
agentStreamHead: nextHead,
|
||||||
|
agentTasks,
|
||||||
messageSubmissions,
|
messageSubmissions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1601,6 +1632,10 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
nextAuthoritative.set(agentId, true);
|
nextAuthoritative.set(agentId, true);
|
||||||
nextSyncGeneration.set(agentId, session.historySyncGeneration);
|
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 {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
@@ -1610,6 +1645,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
...session,
|
...session,
|
||||||
agentStreamTail: nextTail,
|
agentStreamTail: nextTail,
|
||||||
agentStreamHead: nextHead,
|
agentStreamHead: nextHead,
|
||||||
|
agentTasks,
|
||||||
agentTimelineCursor: nextCursor,
|
agentTimelineCursor: nextCursor,
|
||||||
agentTimelineHasOlder: nextHasOlder,
|
agentTimelineHasOlder: nextHasOlder,
|
||||||
agentTimelineHasNewer: nextHasNewer,
|
agentTimelineHasNewer: nextHasNewer,
|
||||||
|
|||||||
@@ -3557,6 +3557,59 @@ describe("processAgentStreamEvent", () => {
|
|||||||
expect(result.sideEffects).toEqual([]);
|
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", () => {
|
it("detects gap and emits catch-up side effect", () => {
|
||||||
const existingCursor: TimelineCursor = {
|
const existingCursor: TimelineCursor = {
|
||||||
epoch: "epoch-1",
|
epoch: "epoch-1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
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 type { TurnLivenessTransition } from "@/timeline/turn-liveness";
|
||||||
import {
|
import {
|
||||||
applyStreamEvent,
|
applyStreamEvent,
|
||||||
@@ -1436,6 +1436,7 @@ export interface ProcessAgentStreamEventOutput {
|
|||||||
cursor: TimelineCursor | null;
|
cursor: TimelineCursor | null;
|
||||||
cursorChanged: boolean;
|
cursorChanged: boolean;
|
||||||
acknowledgedClientMessageIds: string[];
|
acknowledgedClientMessageIds: string[];
|
||||||
|
taskSnapshot?: TodoEntry[];
|
||||||
sideEffects: AgentStreamReducerSideEffect[];
|
sideEffects: AgentStreamReducerSideEffect[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1642,6 +1643,9 @@ export function processAgentStreamEvent(
|
|||||||
cursor: sequencing.nextTimelineCursor,
|
cursor: sequencing.nextTimelineCursor,
|
||||||
cursorChanged: sequencing.cursorChanged,
|
cursorChanged: sequencing.cursorChanged,
|
||||||
acknowledgedClientMessageIds: streamResult.acknowledgedClientMessageIds ?? [],
|
acknowledgedClientMessageIds: streamResult.acknowledgedClientMessageIds ?? [],
|
||||||
|
...(sequencing.shouldApplyStreamEvent && event.type === "timeline" && event.item.type === "todo"
|
||||||
|
? { taskSnapshot: event.item.items }
|
||||||
|
: {}),
|
||||||
sideEffects: sequencing.sideEffects,
|
sideEffects: sequencing.sideEffects,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1667,6 +1671,7 @@ export function processAgentStreamEvents(
|
|||||||
let changedTail = false;
|
let changedTail = false;
|
||||||
let changedHead = false;
|
let changedHead = false;
|
||||||
let cursorChanged = false;
|
let cursorChanged = false;
|
||||||
|
let taskSnapshot: TodoEntry[] | undefined;
|
||||||
const acknowledgedClientMessageIds = new Set<string>();
|
const acknowledgedClientMessageIds = new Set<string>();
|
||||||
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
||||||
|
|
||||||
@@ -1690,6 +1695,9 @@ export function processAgentStreamEvents(
|
|||||||
for (const clientMessageId of result.acknowledgedClientMessageIds) {
|
for (const clientMessageId of result.acknowledgedClientMessageIds) {
|
||||||
acknowledgedClientMessageIds.add(clientMessageId);
|
acknowledgedClientMessageIds.add(clientMessageId);
|
||||||
}
|
}
|
||||||
|
if (result.taskSnapshot !== undefined) {
|
||||||
|
taskSnapshot = result.taskSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
if (result.cursorChanged) {
|
if (result.cursorChanged) {
|
||||||
cursor = result.cursor ?? undefined;
|
cursor = result.cursor ?? undefined;
|
||||||
@@ -1705,6 +1713,7 @@ export function processAgentStreamEvents(
|
|||||||
cursor: cursor ?? null,
|
cursor: cursor ?? null,
|
||||||
cursorChanged,
|
cursorChanged,
|
||||||
acknowledgedClientMessageIds: [...acknowledgedClientMessageIds],
|
acknowledgedClientMessageIds: [...acknowledgedClientMessageIds],
|
||||||
|
...(taskSnapshot !== undefined ? { taskSnapshot } : {}),
|
||||||
sideEffects,
|
sideEffects,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1788,6 +1797,7 @@ interface StreamStatePatch {
|
|||||||
tail?: StreamItem[];
|
tail?: StreamItem[];
|
||||||
head?: StreamItem[];
|
head?: StreamItem[];
|
||||||
acknowledgedClientMessageIds?: readonly string[];
|
acknowledgedClientMessageIds?: readonly string[];
|
||||||
|
taskSnapshot?: TodoEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveAgentStreamTurnLiveness(
|
export function deriveAgentStreamTurnLiveness(
|
||||||
@@ -1850,7 +1860,8 @@ export function createSessionAgentStreamReducerQueue(
|
|||||||
if (
|
if (
|
||||||
result.changedTail ||
|
result.changedTail ||
|
||||||
result.changedHead ||
|
result.changedHead ||
|
||||||
result.acknowledgedClientMessageIds.length > 0
|
result.acknowledgedClientMessageIds.length > 0 ||
|
||||||
|
result.taskSnapshot !== undefined
|
||||||
) {
|
) {
|
||||||
setAgentStreamState(serverId, agentId, {
|
setAgentStreamState(serverId, agentId, {
|
||||||
...(result.changedTail ? { tail: result.tail } : {}),
|
...(result.changedTail ? { tail: result.tail } : {}),
|
||||||
@@ -1858,6 +1869,7 @@ export function createSessionAgentStreamReducerQueue(
|
|||||||
...(result.acknowledgedClientMessageIds.length > 0
|
...(result.acknowledgedClientMessageIds.length > 0
|
||||||
? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds }
|
? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(result.taskSnapshot !== undefined ? { taskSnapshot: result.taskSnapshot } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (result.cursorChanged && result.cursor) {
|
if (result.cursorChanged && result.cursor) {
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "codex",
|
provider,
|
||||||
item: {
|
item: {
|
||||||
type: "todo",
|
type: "todo",
|
||||||
items,
|
items,
|
||||||
@@ -970,6 +979,80 @@ describe("stream reducer canonical tool calls", () => {
|
|||||||
assert.ok(todos);
|
assert.ok(todos);
|
||||||
assert.strictEqual(todos.items.length, 2);
|
assert.strictEqual(todos.items.length, 2);
|
||||||
assert.strictEqual(todos.items[1]?.completed, true);
|
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", () => {
|
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");
|
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", () => {
|
it("preserves submitted user message images when authoritative user message arrives", () => {
|
||||||
const messageId = "msg-user-images";
|
const messageId = "msg-user-images";
|
||||||
const submittedTimestamp = new Date("2025-01-01T11:10:00Z");
|
const submittedTimestamp = new Date("2025-01-01T11:10:00Z");
|
||||||
|
|||||||
@@ -766,8 +766,15 @@ export interface CompactionItem {
|
|||||||
export interface TodoEntry {
|
export interface TodoEntry {
|
||||||
text: string;
|
text: string;
|
||||||
completed: boolean;
|
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 {
|
export interface TodoListItem {
|
||||||
kind: "todo_list";
|
kind: "todo_list";
|
||||||
id: string;
|
id: string;
|
||||||
@@ -775,6 +782,7 @@ export interface TodoListItem {
|
|||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
items: TodoEntry[];
|
items: TodoEntry[];
|
||||||
|
activity: TaskActivity;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StreamUpdateSource = "live" | "canonical";
|
export type StreamUpdateSource = "live" | "canonical";
|
||||||
@@ -1178,34 +1186,103 @@ function appendTodoList(
|
|||||||
const normalizedItems = items.map((item) => ({
|
const normalizedItems = items.map((item) => ({
|
||||||
text: item.text,
|
text: item.text,
|
||||||
completed: item.completed,
|
completed: item.completed,
|
||||||
|
...(item.id ? { id: item.id } : {}),
|
||||||
|
...(item.status ? { status: item.status } : {}),
|
||||||
|
...(item.activeForm ? { activeForm: item.activeForm } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const lastItem = state[state.length - 1];
|
const previousIndex = state.findLastIndex(
|
||||||
if (lastItem && lastItem.kind === "todo_list" && lastItem.provider === provider) {
|
(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 next = [...state];
|
||||||
const updated: TodoListItem = {
|
next[previousIndex] = {
|
||||||
...lastItem,
|
...previous,
|
||||||
...(timelineCursor ? { timelineCursor } : {}),
|
...(timelineCursor ? { timelineCursor } : {}),
|
||||||
items: normalizedItems,
|
items: normalizedItems,
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
next[next.length - 1] = updated;
|
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const idSeed = `${provider}:${JSON.stringify(normalizedItems)}`;
|
const lastItem = state[state.length - 1];
|
||||||
const entryId = createUniqueTimelineId(state, "todo", idSeed, timestamp);
|
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 = {
|
const next = [...state];
|
||||||
kind: "todo_list",
|
for (const activity of activities) {
|
||||||
id: entryId,
|
const idSeed = `${provider}:${JSON.stringify(activity)}:${JSON.stringify(normalizedItems)}`;
|
||||||
...(timelineCursor ? { timelineCursor } : {}),
|
next.push({
|
||||||
timestamp,
|
kind: "todo_list",
|
||||||
provider,
|
id: createUniqueTimelineId(next, "todo", idSeed, timestamp),
|
||||||
items: normalizedItems,
|
...(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(
|
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));
|
const tasks = extractTaskEntriesFromToolCall(item.name, inputFromUnknownDetail(item.detail));
|
||||||
if (tasks) {
|
if (tasks) {
|
||||||
return appendTodoList(
|
return appendTodoList(
|
||||||
@@ -1349,12 +1435,12 @@ function reduceTimelineEvent(
|
|||||||
reduceTimelineToolCall(state, event, item, timestamp, timelineCursor),
|
reduceTimelineToolCall(state, event, item, timestamp, timelineCursor),
|
||||||
);
|
);
|
||||||
case "todo": {
|
case "todo": {
|
||||||
if (event.provider === "claude") {
|
|
||||||
return finalizeActiveThoughts(state);
|
|
||||||
}
|
|
||||||
const items: TodoEntry[] = (item.items ?? []).map((todo) => ({
|
const items: TodoEntry[] = (item.items ?? []).map((todo) => ({
|
||||||
text: todo.text,
|
text: todo.text,
|
||||||
completed: todo.completed,
|
completed: todo.completed,
|
||||||
|
id: todo.id,
|
||||||
|
status: todo.status,
|
||||||
|
activeForm: todo.activeForm,
|
||||||
}));
|
}));
|
||||||
return finalizeActiveThoughts(
|
return finalizeActiveThoughts(
|
||||||
appendTodoList(state, event.provider, items, timestamp, timelineCursor),
|
appendTodoList(state, event.provider, items, timestamp, timelineCursor),
|
||||||
|
|||||||
@@ -339,12 +339,20 @@ export interface CompactionTimelineItem {
|
|||||||
preTokens?: number;
|
preTokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentTaskItem {
|
||||||
|
text: string;
|
||||||
|
completed: boolean;
|
||||||
|
id?: string;
|
||||||
|
status?: "pending" | "in_progress" | "completed";
|
||||||
|
activeForm?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type AgentTimelineItem =
|
export type AgentTimelineItem =
|
||||||
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
|
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
|
||||||
| { type: "assistant_message"; text: string; messageId?: string }
|
| { type: "assistant_message"; text: string; messageId?: string }
|
||||||
| { type: "reasoning"; text: string }
|
| { type: "reasoning"; text: string }
|
||||||
| ToolCallTimelineItem
|
| ToolCallTimelineItem
|
||||||
| { type: "todo"; items: { text: string; completed: boolean }[] }
|
| { type: "todo"; items: AgentTaskItem[] }
|
||||||
| { type: "error"; message: string }
|
| { type: "error"; message: string }
|
||||||
| CompactionTimelineItem;
|
| CompactionTimelineItem;
|
||||||
|
|
||||||
|
|||||||
@@ -659,6 +659,9 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, unknow
|
|||||||
z.object({
|
z.object({
|
||||||
text: z.string(),
|
text: z.string(),
|
||||||
completed: z.boolean(),
|
completed: z.boolean(),
|
||||||
|
id: z.string().optional(),
|
||||||
|
status: z.enum(["pending", "in_progress", "completed"]).optional(),
|
||||||
|
activeForm: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -117,6 +117,40 @@ describe("wire schema compatibility", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
|
||||||
const parsed = LegacySubAgentToolCallSchema.parse({
|
const parsed = LegacySubAgentToolCallSchema.parse({
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentProviderNotice,
|
AgentProviderNotice,
|
||||||
|
AgentTaskItem,
|
||||||
ProviderOptions,
|
ProviderOptions,
|
||||||
ToolPolicy,
|
ToolPolicy,
|
||||||
} from "@getpaseo/protocol/agent-types";
|
} from "@getpaseo/protocol/agent-types";
|
||||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||||
|
|
||||||
export type { AgentProviderNotice };
|
export type { AgentProviderNotice, AgentTaskItem };
|
||||||
|
|
||||||
export type AgentProvider = string;
|
export type AgentProvider = string;
|
||||||
|
|
||||||
@@ -383,7 +384,7 @@ export type AgentTimelineItem =
|
|||||||
| { type: "assistant_message"; text: string; messageId?: string }
|
| { type: "assistant_message"; text: string; messageId?: string }
|
||||||
| { type: "reasoning"; text: string }
|
| { type: "reasoning"; text: string }
|
||||||
| ToolCallTimelineItem
|
| ToolCallTimelineItem
|
||||||
| { type: "todo"; items: { text: string; completed: boolean }[] }
|
| { type: "todo"; items: AgentTaskItem[] }
|
||||||
| { type: "error"; message: string }
|
| { type: "error"; message: string }
|
||||||
| CompactionTimelineItem;
|
| CompactionTimelineItem;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../ag
|
|||||||
|
|
||||||
interface TestClaudeSession {
|
interface TestClaudeSession {
|
||||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
||||||
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
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") {
|
async function collectStreamEvents(session: AgentSession, prompt = "turn") {
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
for await (const event of streamSession(session, prompt)) {
|
for await (const event of streamSession(session, prompt)) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
} from "./model-manifest.js";
|
} from "./model-manifest.js";
|
||||||
import { parsePartialJsonObject } from "./partial-json.js";
|
import { parsePartialJsonObject } from "./partial-json.js";
|
||||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||||
|
import { ClaudeTaskState } from "./task-state.js";
|
||||||
import {
|
import {
|
||||||
ClaudeTaskProtocolSource,
|
ClaudeTaskProtocolSource,
|
||||||
type ClaudeHookObservationInput,
|
type ClaudeHookObservationInput,
|
||||||
@@ -2025,6 +2026,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private autonomousTurn: AutonomousTurnState | null = null;
|
private autonomousTurn: AutonomousTurnState | null = null;
|
||||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||||
private readonly timelineAssembler = new TimelineAssembler();
|
private readonly timelineAssembler = new TimelineAssembler();
|
||||||
|
private readonly taskState = new ClaudeTaskState();
|
||||||
private readonly taskProtocolSource = new ClaudeTaskProtocolSource({
|
private readonly taskProtocolSource = new ClaudeTaskProtocolSource({
|
||||||
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
|
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
|
||||||
readWorkflowResult: readClaudeWorkflowResultFile,
|
readWorkflowResult: readClaudeWorkflowResultFile,
|
||||||
@@ -2794,6 +2796,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.userMessageIds = [];
|
this.userMessageIds = [];
|
||||||
this.emittedUserMessageIds.clear();
|
this.emittedUserMessageIds.clear();
|
||||||
this.rewindTurnAnchors.length = 0;
|
this.rewindTurnAnchors.length = 0;
|
||||||
|
this.taskState.reset();
|
||||||
this.loadPersistedHistory(sessionId);
|
this.loadPersistedHistory(sessionId);
|
||||||
if (oldSessionId && oldSessionId !== sessionId) {
|
if (oldSessionId && oldSessionId !== sessionId) {
|
||||||
this.dispatchEvents([
|
this.dispatchEvents([
|
||||||
@@ -2824,6 +2827,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.userMessageIds = [];
|
this.userMessageIds = [];
|
||||||
this.emittedUserMessageIds.clear();
|
this.emittedUserMessageIds.clear();
|
||||||
this.rewindTurnAnchors.length = 0;
|
this.rewindTurnAnchors.length = 0;
|
||||||
|
this.taskState.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
private rememberUserMessageId(messageId: string | null | undefined): void {
|
private rememberUserMessageId(messageId: string | null | undefined): void {
|
||||||
@@ -3837,6 +3841,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
|
this.appendTaskStateEvent(message, events);
|
||||||
|
|
||||||
// Subagent identity and lifecycle are announced by Claude Code's task protocol, so they are
|
// 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
|
// read rather than inferred from sidechain frames. `task_started` precedes the child's first
|
||||||
@@ -3901,6 +3906,11 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return events;
|
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.
|
* 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 {
|
private loadPersistedHistory(sessionId: string): void {
|
||||||
try {
|
try {
|
||||||
|
this.taskState.reset();
|
||||||
const historyPath = this.resolveHistoryPath(sessionId);
|
const historyPath = this.resolveHistoryPath(sessionId);
|
||||||
if (!historyPath || !fs.existsSync(historyPath)) {
|
if (!historyPath || !fs.existsSync(historyPath)) {
|
||||||
return;
|
return;
|
||||||
@@ -4683,7 +4694,8 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const historyTimestamp = normalizeProviderReplayTimestamp(entry.timestamp);
|
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 =
|
const isVisibleUserEntry =
|
||||||
entry.type === "user" &&
|
entry.type === "user" &&
|
||||||
typeof entry.uuid === "string" &&
|
typeof entry.uuid === "string" &&
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { ClaudeTaskState } from "./task-state.js";
|
||||||
|
|
||||||
|
function toolUse(id: string, name: string, input: Record<string, unknown>) {
|
||||||
|
return { type: "assistant", message: { content: [{ type: "tool_use", id, name, input }] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolResult(id: string, result: Record<string, unknown>) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: 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<string, unknown>): Array<Record<string, unknown>> {
|
||||||
|
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, unknown>): 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<string, unknown>): Record<string, unknown> | 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<string, AgentTaskItem>();
|
||||||
|
private readonly calls = new Map<string, PendingTaskTool>();
|
||||||
|
private readonly appliedResults = new Set<string>();
|
||||||
|
|
||||||
|
observe(value: unknown): Extract<AgentTimelineItem, { type: "todo" }> | null {
|
||||||
|
const message = record(value);
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
let snapshot: Extract<AgentTimelineItem, { type: "todo" }> | 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<AgentTimelineItem, { type: "todo" }> {
|
||||||
|
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<string, unknown> | null,
|
||||||
|
): Extract<AgentTimelineItem, { type: "todo" }> | 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<string, unknown>,
|
||||||
|
result: Record<string, unknown> | null,
|
||||||
|
): Extract<AgentTimelineItem, { type: "todo" }> | 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<string, unknown>,
|
||||||
|
result: Record<string, unknown> | null,
|
||||||
|
): Extract<AgentTimelineItem, { type: "todo" }> | 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<string, unknown> | null,
|
||||||
|
): Extract<AgentTimelineItem, { type: "todo" }> | 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<AgentTimelineItem, { type: "todo" }> {
|
||||||
|
return { type: "todo", items: [...this.tasks.values()] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaskToolName(value: string | undefined): value is TaskToolName {
|
||||||
|
return (
|
||||||
|
value === "TodoWrite" ||
|
||||||
|
value === "TaskCreate" ||
|
||||||
|
value === "TaskUpdate" ||
|
||||||
|
value === "TaskList"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,10 +22,30 @@ import {
|
|||||||
codexAppServerTurnInputFromPrompt,
|
codexAppServerTurnInputFromPrompt,
|
||||||
listCodexSkills,
|
listCodexSkills,
|
||||||
mapCodexPatchNotificationToToolCall,
|
mapCodexPatchNotificationToToolCall,
|
||||||
|
mapCodexPlanUpdateToTodo,
|
||||||
mapCodexPlanToToolCall,
|
mapCodexPlanToToolCall,
|
||||||
normalizeCodexOutputSchema,
|
normalizeCodexOutputSchema,
|
||||||
toAgentUsage,
|
toAgentUsage,
|
||||||
} from "./codex-app-server-agent.js";
|
} 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 { CodexAppServerClient } from "./codex/app-server-transport.js";
|
||||||
import {
|
import {
|
||||||
createFakeCodexAppServer,
|
createFakeCodexAppServer,
|
||||||
|
|||||||
@@ -939,6 +939,26 @@ export function planStepsToMarkdown(steps: Array<{ step: string; status: string
|
|||||||
return normalizePlanMarkdown(lines.join("\n"));
|
return normalizePlanMarkdown(lines.join("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapCodexPlanUpdateToTodo(
|
||||||
|
steps: Array<{ step?: string | null; status?: string | null }>,
|
||||||
|
): Extract<AgentTimelineItem, { type: "todo" }> {
|
||||||
|
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: {
|
export function mapCodexPlanToToolCall(params: {
|
||||||
callId: string;
|
callId: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -5597,6 +5617,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private handlePlanUpdatedNotification(
|
private handlePlanUpdatedNotification(
|
||||||
parsed: Extract<ParsedCodexNotification, { kind: "plan_updated" }>,
|
parsed: Extract<ParsedCodexNotification, { kind: "plan_updated" }>,
|
||||||
): void {
|
): void {
|
||||||
|
if (!this.planModeEnabled) {
|
||||||
|
this.emitEvent({
|
||||||
|
type: "timeline",
|
||||||
|
provider: CODEX_PROVIDER,
|
||||||
|
item: mapCodexPlanUpdateToTodo(parsed.plan),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const timelineItem = mapCodexPlanToToolCall({
|
const timelineItem = mapCodexPlanToToolCall({
|
||||||
callId: `plan:${this.currentTurnId ?? this.currentThreadId ?? "current"}`,
|
callId: `plan:${this.currentTurnId ?? this.currentThreadId ?? "current"}`,
|
||||||
text: planStepsToMarkdown(
|
text: planStepsToMarkdown(
|
||||||
@@ -5608,13 +5636,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
});
|
});
|
||||||
if (timelineItem) {
|
if (timelineItem) {
|
||||||
this.rememberPlanResult(timelineItem);
|
this.rememberPlanResult(timelineItem);
|
||||||
// In plan mode, the same plan is rendered through the synthetic approval
|
// Older Codex app-server builds reported Plan-mode proposals through
|
||||||
// permission. Keep the remembered text for that card, but do not also
|
// turn/plan/updated. Retain that compatibility path only while Plan mode is active.
|
||||||
// emit a static timeline plan panel.
|
return;
|
||||||
if (this.planModeEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const TODO_PHASES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
describe("OMP todo mapper", () => {
|
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(
|
expect(
|
||||||
mapOmpTodoToolResult(
|
mapOmpTodoToolResult(
|
||||||
parseToolResult({
|
parseToolResult({
|
||||||
@@ -37,9 +37,9 @@ describe("OMP todo mapper", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
type: "todo",
|
type: "todo",
|
||||||
items: [
|
items: [
|
||||||
{ text: "alpha task", completed: false },
|
{ text: "alpha task", status: "in_progress", completed: false },
|
||||||
{ text: "beta task", completed: false },
|
{ text: "beta task", status: "pending", completed: false },
|
||||||
{ text: "gamma task", completed: false },
|
{ text: "gamma task", status: "pending", completed: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,9 +48,9 @@ describe("OMP todo mapper", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
type: "todo",
|
type: "todo",
|
||||||
items: [
|
items: [
|
||||||
{ text: "alpha task", completed: true },
|
{ text: "alpha task", status: "completed", completed: true },
|
||||||
{ text: "beta task", completed: false },
|
{ text: "beta task", status: "in_progress", completed: false },
|
||||||
{ text: "gamma task", completed: false },
|
{ text: "gamma task", status: "pending", completed: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -67,8 +67,8 @@ describe("OMP todo mapper", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
type: "todo",
|
type: "todo",
|
||||||
items: [
|
items: [
|
||||||
{ text: "beta task", completed: false },
|
{ text: "beta task", status: "in_progress", completed: false },
|
||||||
{ text: "gamma task", completed: false },
|
{ text: "gamma task", status: "pending", completed: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -89,9 +89,9 @@ describe("OMP todo mapper", () => {
|
|||||||
{
|
{
|
||||||
type: "todo",
|
type: "todo",
|
||||||
items: [
|
items: [
|
||||||
{ text: "alpha task", completed: true },
|
{ text: "alpha task", status: "completed", completed: true },
|
||||||
{ text: "beta task", completed: false },
|
{ text: "beta task", status: "in_progress", completed: false },
|
||||||
{ text: "gamma task", completed: false },
|
{ text: "gamma task", status: "pending", completed: false },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -41,11 +41,18 @@ function mapOmpTodoItems(items: readonly OmpTodoItem[]): AgentTimelineItem | nul
|
|||||||
type: "todo",
|
type: "todo",
|
||||||
items: items.map((item) => ({
|
items: items.map((item) => ({
|
||||||
text: item.content,
|
text: item.content,
|
||||||
|
status: normalizeOmpTodoStatus(item.status),
|
||||||
completed: item.status === "completed",
|
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<string, unknown> | null {
|
function resultDetails(result: OmpToolResult): Record<string, unknown> | null {
|
||||||
if (typeof result === "string" || result === null) {
|
if (typeof result === "string" || result === null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1878,7 +1878,13 @@ describe("OpenCode adapter startTurn error handling", () => {
|
|||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
item: {
|
item: {
|
||||||
type: "todo",
|
type: "todo",
|
||||||
items: [{ text: "Inspect current directory and existing files", completed: true }],
|
items: [
|
||||||
|
{
|
||||||
|
text: "Inspect current directory and existing files",
|
||||||
|
status: "completed",
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1905,6 +1905,7 @@ function mapOpenCodeTodosToTimelineItems(
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
text,
|
text,
|
||||||
|
status: normalizeOpenCodeTodoStatus(todo.status),
|
||||||
completed: todo.status === "completed",
|
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(
|
function createCompactionTimelineItem(
|
||||||
status: Extract<AgentTimelineItem, { type: "compaction" }>["status"],
|
status: Extract<AgentTimelineItem, { type: "compaction" }>["status"],
|
||||||
trigger?: Extract<AgentTimelineItem, { type: "compaction" }>["trigger"],
|
trigger?: Extract<AgentTimelineItem, { type: "compaction" }>["trigger"],
|
||||||
|
|||||||
@@ -680,8 +680,8 @@ describe("translateOpenCodeEvent", () => {
|
|||||||
item: {
|
item: {
|
||||||
type: "todo",
|
type: "todo",
|
||||||
items: [
|
items: [
|
||||||
{ text: "Outline", completed: false },
|
{ text: "Outline", status: "pending", completed: false },
|
||||||
{ text: "Ship", completed: true },
|
{ text: "Ship", status: "completed", completed: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user