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:
@@ -1,12 +1,13 @@
|
||||
import type {
|
||||
AgentProviderNotice,
|
||||
AgentTaskItem,
|
||||
ProviderOptions,
|
||||
ToolPolicy,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
|
||||
export type { AgentProviderNotice };
|
||||
export type { AgentProviderNotice, AgentTaskItem };
|
||||
|
||||
export type AgentProvider = string;
|
||||
|
||||
@@ -383,7 +384,7 @@ export type AgentTimelineItem =
|
||||
| { type: "assistant_message"; text: string; messageId?: string }
|
||||
| { type: "reasoning"; text: string }
|
||||
| ToolCallTimelineItem
|
||||
| { type: "todo"; items: { text: string; completed: boolean }[] }
|
||||
| { type: "todo"; items: AgentTaskItem[] }
|
||||
| { type: "error"; message: string }
|
||||
| CompactionTimelineItem;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../ag
|
||||
|
||||
interface TestClaudeSession {
|
||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1388,6 +1389,52 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
});
|
||||
}
|
||||
|
||||
test("emits canonical task snapshots from Claude TaskCreate results", async () => {
|
||||
const session = await createSessionForTest();
|
||||
try {
|
||||
session.translateMessageToEvents({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "task-create-1",
|
||||
name: "TaskCreate",
|
||||
input: { subject: "Inspect provider", activeForm: "Inspecting provider" },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as SDKMessage);
|
||||
|
||||
const events = session.translateMessageToEvents({
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }],
|
||||
},
|
||||
toolUseResult: { task: { id: "1", subject: "Inspect provider" } },
|
||||
} as unknown as SDKMessage);
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "todo",
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
text: "Inspect provider",
|
||||
activeForm: "Inspecting provider",
|
||||
status: "pending",
|
||||
completed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function collectStreamEvents(session: AgentSession, prompt = "turn") {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamSession(session, prompt)) {
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "./model-manifest.js";
|
||||
import { parsePartialJsonObject } from "./partial-json.js";
|
||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||
import { ClaudeTaskState } from "./task-state.js";
|
||||
import {
|
||||
ClaudeTaskProtocolSource,
|
||||
type ClaudeHookObservationInput,
|
||||
@@ -2025,6 +2026,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private autonomousTurn: AutonomousTurnState | null = null;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private readonly timelineAssembler = new TimelineAssembler();
|
||||
private readonly taskState = new ClaudeTaskState();
|
||||
private readonly taskProtocolSource = new ClaudeTaskProtocolSource({
|
||||
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
|
||||
readWorkflowResult: readClaudeWorkflowResultFile,
|
||||
@@ -2794,6 +2796,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.userMessageIds = [];
|
||||
this.emittedUserMessageIds.clear();
|
||||
this.rewindTurnAnchors.length = 0;
|
||||
this.taskState.reset();
|
||||
this.loadPersistedHistory(sessionId);
|
||||
if (oldSessionId && oldSessionId !== sessionId) {
|
||||
this.dispatchEvents([
|
||||
@@ -2824,6 +2827,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.userMessageIds = [];
|
||||
this.emittedUserMessageIds.clear();
|
||||
this.rewindTurnAnchors.length = 0;
|
||||
this.taskState.reset();
|
||||
}
|
||||
|
||||
private rememberUserMessageId(messageId: string | null | undefined): void {
|
||||
@@ -3837,6 +3841,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
this.appendTaskStateEvent(message, events);
|
||||
|
||||
// Subagent identity and lifecycle are announced by Claude Code's task protocol, so they are
|
||||
// read rather than inferred from sidechain frames. `task_started` precedes the child's first
|
||||
@@ -3901,6 +3906,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return events;
|
||||
}
|
||||
|
||||
private appendTaskStateEvent(message: SDKMessage, events: AgentStreamEvent[]): void {
|
||||
const item = this.taskState.observe(message);
|
||||
if (item) events.push({ type: "timeline", provider: "claude", item });
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame from inside a subagent, routed to that child rather than the parent's transcript.
|
||||
*/
|
||||
@@ -4559,6 +4569,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
private loadPersistedHistory(sessionId: string): void {
|
||||
try {
|
||||
this.taskState.reset();
|
||||
const historyPath = this.resolveHistoryPath(sessionId);
|
||||
if (!historyPath || !fs.existsSync(historyPath)) {
|
||||
return;
|
||||
@@ -4683,7 +4694,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
const historyTimestamp = normalizeProviderReplayTimestamp(entry.timestamp);
|
||||
const items = this.convertHistoryEntry(entry);
|
||||
const taskSnapshot = this.taskState.observe(entry);
|
||||
const items = [...(taskSnapshot ? [taskSnapshot] : []), ...this.convertHistoryEntry(entry)];
|
||||
const isVisibleUserEntry =
|
||||
entry.type === "user" &&
|
||||
typeof entry.uuid === "string" &&
|
||||
|
||||
@@ -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,
|
||||
listCodexSkills,
|
||||
mapCodexPatchNotificationToToolCall,
|
||||
mapCodexPlanUpdateToTodo,
|
||||
mapCodexPlanToToolCall,
|
||||
normalizeCodexOutputSchema,
|
||||
toAgentUsage,
|
||||
} from "./codex-app-server-agent.js";
|
||||
|
||||
describe("mapCodexPlanUpdateToTodo", () => {
|
||||
test("preserves checklist progress without creating a plan card", () => {
|
||||
expect(
|
||||
mapCodexPlanUpdateToTodo([
|
||||
{ step: "Inspect", status: "completed" },
|
||||
{ step: "Implement", status: "inProgress" },
|
||||
{ step: "Verify", status: "pending" },
|
||||
]),
|
||||
).toEqual({
|
||||
type: "todo",
|
||||
items: [
|
||||
{ id: "0", text: "Inspect", status: "completed", completed: true },
|
||||
{ id: "1", text: "Implement", status: "in_progress", completed: false },
|
||||
{ id: "2", text: "Verify", status: "pending", completed: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
import { CodexAppServerClient } from "./codex/app-server-transport.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
|
||||
@@ -939,6 +939,26 @@ export function planStepsToMarkdown(steps: Array<{ step: string; status: string
|
||||
return normalizePlanMarkdown(lines.join("\n"));
|
||||
}
|
||||
|
||||
export function mapCodexPlanUpdateToTodo(
|
||||
steps: Array<{ step?: string | null; status?: string | null }>,
|
||||
): Extract<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: {
|
||||
callId: string;
|
||||
text: string;
|
||||
@@ -5597,6 +5617,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private handlePlanUpdatedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "plan_updated" }>,
|
||||
): void {
|
||||
if (!this.planModeEnabled) {
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: mapCodexPlanUpdateToTodo(parsed.plan),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const timelineItem = mapCodexPlanToToolCall({
|
||||
callId: `plan:${this.currentTurnId ?? this.currentThreadId ?? "current"}`,
|
||||
text: planStepsToMarkdown(
|
||||
@@ -5608,13 +5636,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
if (timelineItem) {
|
||||
this.rememberPlanResult(timelineItem);
|
||||
// In plan mode, the same plan is rendered through the synthetic approval
|
||||
// permission. Keep the remembered text for that card, but do not also
|
||||
// emit a static timeline plan panel.
|
||||
if (this.planModeEnabled) {
|
||||
return;
|
||||
}
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
// Older Codex app-server builds reported Plan-mode proposals through
|
||||
// turn/plan/updated. Retain that compatibility path only while Plan mode is active.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ const TODO_PHASES = [
|
||||
] as const;
|
||||
|
||||
describe("OMP todo mapper", () => {
|
||||
test("maps todo tool results and collapses statuses to completed booleans", () => {
|
||||
test("maps todo tool results without losing progress status", () => {
|
||||
expect(
|
||||
mapOmpTodoToolResult(
|
||||
parseToolResult({
|
||||
@@ -37,9 +37,9 @@ describe("OMP todo mapper", () => {
|
||||
).toEqual({
|
||||
type: "todo",
|
||||
items: [
|
||||
{ text: "alpha task", completed: false },
|
||||
{ text: "beta task", completed: false },
|
||||
{ text: "gamma task", completed: false },
|
||||
{ text: "alpha task", status: "in_progress", completed: false },
|
||||
{ text: "beta task", status: "pending", completed: false },
|
||||
{ text: "gamma task", status: "pending", completed: false },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -48,9 +48,9 @@ describe("OMP todo mapper", () => {
|
||||
).toEqual({
|
||||
type: "todo",
|
||||
items: [
|
||||
{ text: "alpha task", completed: true },
|
||||
{ text: "beta task", completed: false },
|
||||
{ text: "gamma task", completed: false },
|
||||
{ text: "alpha task", status: "completed", completed: true },
|
||||
{ text: "beta task", status: "in_progress", completed: false },
|
||||
{ text: "gamma task", status: "pending", completed: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -67,8 +67,8 @@ describe("OMP todo mapper", () => {
|
||||
).toEqual({
|
||||
type: "todo",
|
||||
items: [
|
||||
{ text: "beta task", completed: false },
|
||||
{ text: "gamma task", completed: false },
|
||||
{ text: "beta task", status: "in_progress", completed: false },
|
||||
{ text: "gamma task", status: "pending", completed: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -89,9 +89,9 @@ describe("OMP todo mapper", () => {
|
||||
{
|
||||
type: "todo",
|
||||
items: [
|
||||
{ text: "alpha task", completed: true },
|
||||
{ text: "beta task", completed: false },
|
||||
{ text: "gamma task", completed: false },
|
||||
{ text: "alpha task", status: "completed", completed: true },
|
||||
{ text: "beta task", status: "in_progress", completed: false },
|
||||
{ text: "gamma task", status: "pending", completed: false },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -41,11 +41,18 @@ function mapOmpTodoItems(items: readonly OmpTodoItem[]): AgentTimelineItem | nul
|
||||
type: "todo",
|
||||
items: items.map((item) => ({
|
||||
text: item.content,
|
||||
status: normalizeOmpTodoStatus(item.status),
|
||||
completed: item.status === "completed",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOmpTodoStatus(status: OmpTodoItem["status"]) {
|
||||
if (status === "completed") return "completed" as const;
|
||||
if (status === "in_progress") return "in_progress" as const;
|
||||
return "pending" as const;
|
||||
}
|
||||
|
||||
function resultDetails(result: OmpToolResult): Record<string, unknown> | null {
|
||||
if (typeof result === "string" || result === null) {
|
||||
return null;
|
||||
|
||||
@@ -1878,7 +1878,13 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
provider: "opencode",
|
||||
item: {
|
||||
type: "todo",
|
||||
items: [{ text: "Inspect current directory and existing files", completed: true }],
|
||||
items: [
|
||||
{
|
||||
text: "Inspect current directory and existing files",
|
||||
status: "completed",
|
||||
completed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1905,6 +1905,7 @@ function mapOpenCodeTodosToTimelineItems(
|
||||
return [
|
||||
{
|
||||
text,
|
||||
status: normalizeOpenCodeTodoStatus(todo.status),
|
||||
completed: todo.status === "completed",
|
||||
},
|
||||
];
|
||||
@@ -1912,6 +1913,12 @@ function mapOpenCodeTodosToTimelineItems(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOpenCodeTodoStatus(status?: string | null) {
|
||||
if (status === "completed") return "completed" as const;
|
||||
if (status === "in_progress" || status === "inProgress") return "in_progress" as const;
|
||||
return "pending" as const;
|
||||
}
|
||||
|
||||
function createCompactionTimelineItem(
|
||||
status: Extract<AgentTimelineItem, { type: "compaction" }>["status"],
|
||||
trigger?: Extract<AgentTimelineItem, { type: "compaction" }>["trigger"],
|
||||
|
||||
@@ -680,8 +680,8 @@ describe("translateOpenCodeEvent", () => {
|
||||
item: {
|
||||
type: "todo",
|
||||
items: [
|
||||
{ text: "Outline", completed: false },
|
||||
{ text: "Ship", completed: true },
|
||||
{ text: "Outline", status: "pending", completed: false },
|
||||
{ text: "Ship", status: "completed", completed: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user