Show live task progress while agents work (#3227)

* feat(agents): surface live task progress

Keep current task state above the composer and turn task snapshots into semantic timeline activity. Normalize provider task protocols while preserving genuine plan flows.

* fix(agents): admit tasks through stream sequencing

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