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:
@@ -80,6 +80,62 @@ function permutations<T>(values: readonly T[]): T[][] {
|
||||
);
|
||||
}
|
||||
|
||||
function taskTexts(tasks: ReadonlyArray<{ text: string }>): string[] {
|
||||
return tasks.map((task) => task.text);
|
||||
}
|
||||
|
||||
describe("agent task state", () => {
|
||||
it("notifies the task subscriber only when its task snapshot changes", () => {
|
||||
initializeTestSession();
|
||||
const snapshots: string[][] = [];
|
||||
const unsubscribe = useSessionStore.subscribe(
|
||||
(state) => state.sessions["test-server"]?.agentTasks.get("agent-1") ?? [],
|
||||
(tasks) => snapshots.push(taskTexts(tasks)),
|
||||
);
|
||||
|
||||
const tasks = [{ id: "1", text: "Inspect", status: "pending" as const, completed: false }];
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentStreamState("test-server", "agent-1", { taskSnapshot: tasks });
|
||||
useSessionStore.getState().setMessages("test-server", []);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentStreamState("test-server", "agent-1", { taskSnapshot: [...tasks] });
|
||||
useSessionStore.getState().setAgentStreamState("test-server", "agent-1", {
|
||||
taskSnapshot: [{ ...tasks[0], status: "completed", completed: true }],
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
expect(snapshots).toEqual([["Inspect"], ["Inspect"]]);
|
||||
});
|
||||
|
||||
it("restores the latest task snapshot from an authoritative timeline", () => {
|
||||
initializeTestSession();
|
||||
const todo: StreamItem = {
|
||||
kind: "todo_list",
|
||||
id: "todo-1",
|
||||
provider: "codex",
|
||||
timestamp: new Date("2026-08-11T10:00:00.000Z"),
|
||||
activity: { type: "started", task: "Verify" },
|
||||
items: [{ text: "Verify", status: "in_progress", completed: false }],
|
||||
};
|
||||
|
||||
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
|
||||
items: [todo],
|
||||
head: [],
|
||||
range: null,
|
||||
older: "none",
|
||||
newer: false,
|
||||
synchronized: true,
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
|
||||
expect(useSessionStore.getState().sessions["test-server"]?.agentTasks.get("agent-1")).toEqual(
|
||||
todo.items,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent timeline state", () => {
|
||||
it("commits canonical items, range, and older availability as one synced state", () => {
|
||||
initializeTestSession();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
handoffCreatedAgentUserMessageToStream,
|
||||
removeSubmittedUserMessage,
|
||||
type StreamItem,
|
||||
type TodoEntry,
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
import {
|
||||
@@ -394,6 +395,26 @@ export function selectAgentTurnPresentation(
|
||||
);
|
||||
}
|
||||
|
||||
function latestTasksFromStream(items: readonly StreamItem[]): TodoEntry[] {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (item?.kind === "todo_list") return item.items;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function updateAgentTasks(
|
||||
current: Map<string, TodoEntry[]>,
|
||||
agentId: string,
|
||||
taskSnapshot: TodoEntry[] | undefined,
|
||||
): Map<string, TodoEntry[]> {
|
||||
if (taskSnapshot === undefined || equal(current.get(agentId) ?? [], taskSnapshot)) return current;
|
||||
const next = new Map(current);
|
||||
if (taskSnapshot.length > 0) next.set(agentId, taskSnapshot);
|
||||
else next.delete(agentId);
|
||||
return next;
|
||||
}
|
||||
|
||||
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
|
||||
|
||||
// Per-session state
|
||||
@@ -426,6 +447,7 @@ export interface SessionState {
|
||||
// Stream state (head/tail model)
|
||||
agentStreamTail: Map<string, StreamItem[]>;
|
||||
agentStreamHead: Map<string, StreamItem[]>;
|
||||
agentTasks: Map<string, TodoEntry[]>;
|
||||
agentTurnLiveness: Map<string, TurnLiveness>;
|
||||
messageSubmissions: Map<string, MessageSubmissionRecord[]>;
|
||||
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
||||
@@ -523,6 +545,7 @@ interface SessionStoreActions {
|
||||
tail?: StreamItem[];
|
||||
head?: StreamItem[];
|
||||
acknowledgedClientMessageIds?: readonly string[];
|
||||
taskSnapshot?: TodoEntry[];
|
||||
},
|
||||
) => void;
|
||||
applyAgentTurnLiveness: (
|
||||
@@ -692,6 +715,7 @@ function createInitialSessionState(
|
||||
currentAssistantMessage: "",
|
||||
agentStreamTail: new Map(),
|
||||
agentStreamHead: new Map(),
|
||||
agentTasks: new Map(),
|
||||
agentTurnLiveness: new Map(),
|
||||
messageSubmissions: new Map(),
|
||||
agentTimelineCursor: new Map(),
|
||||
@@ -812,8 +836,11 @@ export const useSessionStore = create<SessionStore>()(
|
||||
const session = createInitialSessionState(serverId, null);
|
||||
const timeline = replica.timeline;
|
||||
const agentStreamTail = new Map<string, StreamItem[]>();
|
||||
const agentTasks = new Map<string, TodoEntry[]>();
|
||||
if (timeline) {
|
||||
agentStreamTail.set(timeline.agentId, timeline.items);
|
||||
const tasks = latestTasksFromStream(timeline.items);
|
||||
if (tasks.length > 0) agentTasks.set(timeline.agentId, tasks);
|
||||
}
|
||||
const agentLastActivity = new Map(prev.agentLastActivity);
|
||||
for (const agent of replica.agents.values()) {
|
||||
@@ -830,6 +857,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
workspaces: replica.workspaces,
|
||||
projects: replica.projects,
|
||||
agentStreamTail,
|
||||
agentTasks,
|
||||
},
|
||||
},
|
||||
agentLastActivity,
|
||||
@@ -1149,8 +1177,10 @@ export const useSessionStore = create<SessionStore>()(
|
||||
state.acknowledgedClientMessageIds ?? [],
|
||||
);
|
||||
const changedSubmissions = observedSubmissions !== currentSubmissions;
|
||||
const agentTasks = updateAgentTasks(session.agentTasks, agentId, state.taskSnapshot);
|
||||
const changedTasks = agentTasks !== session.agentTasks;
|
||||
|
||||
if (!changedTail && !changedHead && !changedSubmissions) {
|
||||
if (!changedTail && !changedHead && !changedSubmissions && !changedTasks) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
@@ -1172,6 +1202,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...session,
|
||||
agentStreamTail: nextTail,
|
||||
agentStreamHead: nextHead,
|
||||
agentTasks,
|
||||
messageSubmissions,
|
||||
},
|
||||
},
|
||||
@@ -1601,6 +1632,10 @@ export const useSessionStore = create<SessionStore>()(
|
||||
nextAuthoritative.set(agentId, true);
|
||||
nextSyncGeneration.set(agentId, session.historySyncGeneration);
|
||||
}
|
||||
const tasks = latestTasksFromStream([...state.items, ...state.head]);
|
||||
const agentTasks = new Map(session.agentTasks);
|
||||
if (tasks.length > 0) agentTasks.set(agentId, tasks);
|
||||
else agentTasks.delete(agentId);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
@@ -1610,6 +1645,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...session,
|
||||
agentStreamTail: nextTail,
|
||||
agentStreamHead: nextHead,
|
||||
agentTasks,
|
||||
agentTimelineCursor: nextCursor,
|
||||
agentTimelineHasOlder: nextHasOlder,
|
||||
agentTimelineHasNewer: nextHasNewer,
|
||||
|
||||
Reference in New Issue
Block a user