diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
index 9e7d8a64..b8d90b05 100644
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -2,6 +2,7 @@
import "../index.css";
import {
+ EventId,
type GitBranch,
ORCHESTRATION_WS_METHODS,
type MessageId,
@@ -40,6 +41,12 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z";
const BASE_TIME_MS = Date.parse(NOW_ISO);
const ATTACHMENT_SVG = "";
const ONBOARDING_STORAGE_KEY = "okcode:onboarding-completed:v1";
+const PLAN_FOLLOW_UP_TURN_ID = "turn-plan-follow-up" as TurnId;
+const PLAN_IMPLEMENTATION_TURN_ID = "turn-plan-implementation" as TurnId;
+const PLAN_FOLLOW_UP_ID = "plan-follow-up-browser";
+const PLAN_STEP_INSPECT_FILES = "Inspect files";
+const PLAN_STEP_APPLY_PATCH = "Apply patch";
+const PLAN_STEP_VERIFY_SIDEBAR = "Verify sidebar stability";
interface WsRequestEnvelope {
id: string;
@@ -462,6 +469,147 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
};
}
+function replaceThreadInSnapshot(
+ snapshot: OrchestrationReadModel,
+ updater: (
+ thread: OrchestrationReadModel["threads"][number],
+ ) => OrchestrationReadModel["threads"][number],
+): OrchestrationReadModel {
+ const nextThreadIndex = snapshot.threads.findIndex((thread) => thread.id === THREAD_ID);
+ if (nextThreadIndex < 0) {
+ return snapshot;
+ }
+ const nextThreads = [...snapshot.threads];
+ nextThreads[nextThreadIndex] = updater(nextThreads[nextThreadIndex]!);
+ return {
+ ...snapshot,
+ threads: nextThreads,
+ };
+}
+
+function createPlanFollowUpSnapshot(): OrchestrationReadModel {
+ const snapshot = createSnapshotForTargetUser({
+ targetMessageId: "msg-user-plan-follow-up-target" as MessageId,
+ targetText: "plan follow-up thread",
+ });
+ const planMarkdown = [
+ "# Stabilize plan implementation flow",
+ "",
+ `- ${PLAN_STEP_INSPECT_FILES}`,
+ `- ${PLAN_STEP_APPLY_PATCH}`,
+ `- ${PLAN_STEP_VERIFY_SIDEBAR}`,
+ ].join("\n");
+
+ return replaceThreadInSnapshot(snapshot, (thread) => {
+ if (!thread.session) {
+ return thread;
+ }
+ return {
+ ...thread,
+ interactionMode: "plan",
+ latestTurn: {
+ turnId: PLAN_FOLLOW_UP_TURN_ID,
+ state: "completed",
+ requestedAt: isoAt(1_100),
+ startedAt: isoAt(1_101),
+ completedAt: isoAt(1_102),
+ assistantMessageId: null,
+ },
+ proposedPlans: [
+ {
+ id: PLAN_FOLLOW_UP_ID,
+ turnId: PLAN_FOLLOW_UP_TURN_ID,
+ planMarkdown,
+ implementedAt: null,
+ implementationThreadId: null,
+ createdAt: isoAt(1_100),
+ updatedAt: isoAt(1_101),
+ },
+ ],
+ session: {
+ ...thread.session,
+ status: "ready",
+ activeTurnId: null,
+ updatedAt: isoAt(1_102),
+ },
+ updatedAt: isoAt(1_102),
+ };
+ });
+}
+
+function createRunningPlanImplementationSnapshot(snapshotSequence: number): OrchestrationReadModel {
+ const snapshot = createPlanFollowUpSnapshot();
+ const snapshotWithRunningThread = replaceThreadInSnapshot(snapshot, (thread) => {
+ if (!thread.session) {
+ return thread;
+ }
+ const activities: OrchestrationReadModel["threads"][number]["activities"] = [
+ {
+ id: EventId.makeUnsafe("evt-plan-implementation-1"),
+ tone: "info",
+ kind: "turn.plan.updated",
+ summary: "Plan updated",
+ payload: {
+ explanation: "Executing the queued plan steps.",
+ plan: [
+ { step: PLAN_STEP_INSPECT_FILES, status: "completed" },
+ { step: PLAN_STEP_APPLY_PATCH, status: "in_progress" },
+ { step: PLAN_STEP_VERIFY_SIDEBAR, status: "pending" },
+ ],
+ },
+ turnId: PLAN_IMPLEMENTATION_TURN_ID,
+ sequence: 40,
+ createdAt: isoAt(1_202),
+ },
+ {
+ id: EventId.makeUnsafe("evt-plan-implementation-2"),
+ tone: "tool",
+ kind: "tool.updated",
+ summary: "Editing files",
+ payload: {
+ itemType: "command_execution",
+ status: "in_progress",
+ title: "Apply patch",
+ detail: "Editing files",
+ },
+ turnId: PLAN_IMPLEMENTATION_TURN_ID,
+ sequence: 41,
+ createdAt: isoAt(1_203),
+ },
+ ];
+
+ return {
+ ...thread,
+ interactionMode: "code",
+ latestTurn: {
+ turnId: PLAN_IMPLEMENTATION_TURN_ID,
+ state: "running",
+ requestedAt: isoAt(1_200),
+ startedAt: isoAt(1_201),
+ completedAt: null,
+ assistantMessageId: null,
+ sourceProposedPlan: {
+ threadId: THREAD_ID,
+ planId: PLAN_FOLLOW_UP_ID,
+ },
+ },
+ activities,
+ session: {
+ ...thread.session,
+ status: "running",
+ activeTurnId: PLAN_IMPLEMENTATION_TURN_ID,
+ updatedAt: isoAt(1_204),
+ },
+ updatedAt: isoAt(1_204),
+ };
+ });
+
+ return {
+ ...snapshotWithRunningThread,
+ snapshotSequence,
+ };
+}
+
function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
const tag = body._tag;
const fixtureThread = fixture.snapshot.threads.find((thread) => thread.id === THREAD_ID) ?? null;
@@ -679,6 +827,24 @@ async function waitForSendButton(): Promise {
);
}
+async function waitForPlanFollowUpImplementButton(): Promise {
+ return waitForElement(
+ () =>
+ [...document.querySelectorAll('button[type="submit"]')].find(
+ (button) => button.textContent?.trim() === "Implement",
+ ) ?? null,
+ "Unable to find plan implement button.",
+ );
+}
+
+function submitWithButton(button: HTMLButtonElement): void {
+ const form = button.closest("form");
+ if (!(form instanceof HTMLFormElement)) {
+ throw new Error("Unable to locate composer form for submit button.");
+ }
+ form.requestSubmit(button);
+}
+
function isVisibleElement(element: Element | null): element is HTMLElement {
return (
element instanceof HTMLElement &&
@@ -910,6 +1076,15 @@ async function mountChatView(options: {
};
}
+async function syncFixtureSnapshot(snapshot: OrchestrationReadModel): Promise {
+ fixture = {
+ ...fixture,
+ snapshot,
+ };
+ useStore.getState().syncServerReadModel(snapshot);
+ await waitForLayout();
+}
+
async function measureUserRowAtViewport(options: {
snapshot: OrchestrationReadModel;
targetMessageId: MessageId;
@@ -1006,6 +1181,141 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});
+ it("implements a proposed plan on the same thread without tripping a render loop", async () => {
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: createPlanFollowUpSnapshot(),
+ });
+
+ try {
+ await waitForServerConfigToApply();
+ await vi.waitFor(async () => {
+ expect(await readCurrentInteractionModeLabel()).toBe("Plan");
+ });
+
+ const sendButton = await waitForPlanFollowUpImplementButton();
+ await vi.waitFor(() => {
+ expect(sendButton.disabled).toBe(false);
+ });
+ submitWithButton(sendButton);
+
+ await vi.waitFor(() => {
+ const request = wsRequests.find(
+ (entry) =>
+ entry._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
+ typeof entry.command === "object" &&
+ entry.command !== null &&
+ "type" in entry.command &&
+ entry.command.type === "thread.turn.start" &&
+ "threadId" in entry.command &&
+ entry.command.threadId === THREAD_ID,
+ );
+ expect(request).toMatchObject({
+ _tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
+ command: {
+ type: "thread.turn.start",
+ threadId: THREAD_ID,
+ interactionMode: "code",
+ sourceProposedPlan: {
+ threadId: THREAD_ID,
+ planId: PLAN_FOLLOW_UP_ID,
+ },
+ },
+ });
+ });
+
+ await vi.waitFor(async () => {
+ expect(await readCurrentInteractionModeLabel()).toBe("Code");
+ });
+
+ await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(2));
+
+ await vi.waitFor(
+ () => {
+ expect(
+ document.querySelector('button[aria-label="Close plan sidebar"]'),
+ ).toBeTruthy();
+ expect(document.body.textContent).toContain(PLAN_STEP_INSPECT_FILES);
+ expect(document.body.textContent).toContain(PLAN_STEP_APPLY_PATCH);
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+
+ expect(
+ consoleErrorSpy.mock.calls.some((call) =>
+ call.some(
+ (value) =>
+ typeof value === "string" &&
+ (value.includes("Too many re-renders") ||
+ value.includes("Minified React error #301")),
+ ),
+ ),
+ ).toBe(false);
+ } finally {
+ consoleErrorSpy.mockRestore();
+ await mounted.cleanup();
+ }
+ });
+
+ it("keeps the same-thread implementation surface stable across repeated identical snapshots", async () => {
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: createPlanFollowUpSnapshot(),
+ });
+
+ try {
+ await waitForServerConfigToApply();
+ const sendButton = await waitForPlanFollowUpImplementButton();
+ await vi.waitFor(() => {
+ expect(sendButton.disabled).toBe(false);
+ });
+ submitWithButton(sendButton);
+
+ await vi.waitFor(() => {
+ const request = wsRequests.find(
+ (entry) =>
+ entry._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
+ typeof entry.command === "object" &&
+ entry.command !== null &&
+ "type" in entry.command &&
+ entry.command.type === "thread.turn.start" &&
+ "threadId" in entry.command &&
+ entry.command.threadId === THREAD_ID,
+ );
+ expect(request).toBeTruthy();
+ });
+
+ await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(2));
+ await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(3));
+ await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(4));
+
+ await vi.waitFor(
+ () => {
+ expect(document.body.textContent).toContain(PLAN_STEP_VERIFY_SIDEBAR);
+ expect(document.body.textContent).not.toContain("Plan ready");
+ expect(document.querySelectorAll('[aria-label="Close plan sidebar"]')).toHaveLength(1);
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+
+ expect(
+ consoleErrorSpy.mock.calls.some((call) =>
+ call.some(
+ (value) =>
+ typeof value === "string" &&
+ (value.includes("Too many re-renders") ||
+ value.includes("Minified React error #301")),
+ ),
+ ),
+ ).toBe(false);
+ } finally {
+ consoleErrorSpy.mockRestore();
+ await mounted.cleanup();
+ }
+ });
+
it.each(TEXT_VIEWPORT_MATRIX)(
"keeps long user message estimate close at the $name viewport",
async (viewport) => {
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index d85d0713..83ada820 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -69,19 +69,20 @@ import {
replaceTextRange,
} from "../composer-logic";
import {
- derivePendingApprovals,
- derivePendingUserInputs,
+ derivePendingApprovalsFromOrderedActivities,
+ derivePendingUserInputsFromOrderedActivities,
derivePhase,
deriveTimelineEntries,
deriveActiveWorkStartedAt,
- deriveActivePlanState,
+ deriveActivePlanStateFromOrderedActivities,
findSidebarProposedPlan,
findLatestProposedPlan,
- deriveWorkLogEntries,
+ deriveWorkLogEntriesFromOrderedActivities,
hasActionableProposedPlan,
hasToolActivityForTurn,
isLatestTurnSettled,
formatElapsed,
+ orderThreadActivities,
} from "../session-logic";
import { isScrollContainerNearBottom } from "../chat-scroll";
import {
@@ -1001,21 +1002,29 @@ export default function ChatView({
sendStartedAt,
);
const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
+ const orderedThreadActivities = useMemo(
+ () => orderThreadActivities(threadActivities),
+ [threadActivities],
+ );
const workLogEntries = useMemo(
- () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined),
- [activeLatestTurn?.turnId, threadActivities],
+ () =>
+ deriveWorkLogEntriesFromOrderedActivities(
+ orderedThreadActivities,
+ activeLatestTurn?.turnId ?? undefined,
+ ),
+ [activeLatestTurn?.turnId, orderedThreadActivities],
);
const latestTurnHasToolActivity = useMemo(
() => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId),
[activeLatestTurn?.turnId, threadActivities],
);
const pendingApprovals = useMemo(
- () => derivePendingApprovals(threadActivities),
- [threadActivities],
+ () => derivePendingApprovalsFromOrderedActivities(orderedThreadActivities),
+ [orderedThreadActivities],
);
const pendingUserInputs = useMemo(
- () => derivePendingUserInputs(threadActivities),
- [threadActivities],
+ () => derivePendingUserInputsFromOrderedActivities(orderedThreadActivities),
+ [orderedThreadActivities],
);
const activePendingUserInput = pendingUserInputs[0] ?? null;
const activePendingDraftAnswers = useMemo(
@@ -1070,14 +1079,20 @@ export default function ChatView({
[activeLatestTurn, activeThread?.id, latestTurnSettled, threads],
);
const activePlan = useMemo(
- () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
- [activeLatestTurn?.turnId, threadActivities],
+ () =>
+ deriveActivePlanStateFromOrderedActivities(
+ orderedThreadActivities,
+ activeLatestTurn?.turnId ?? undefined,
+ ),
+ [activeLatestTurn?.turnId, orderedThreadActivities],
);
const activePlanTurnId = activePlan?.turnId ?? null;
const activePendingUserInputRequestId = activePendingUserInput?.requestId ?? null;
const hasPendingPlanFeedback =
activePendingUserInputRequestId !== null &&
(activePlanTurnId !== null || interactionMode === "plan");
+ const planSidebarAutoOpenTurnKey =
+ activePlanTurnId ?? sidebarProposedPlan?.turnId ?? activeLatestTurn?.turnId ?? null;
const showPlanFollowUpPrompt =
pendingUserInputs.length === 0 &&
interactionMode === "plan" &&
@@ -1133,21 +1148,17 @@ export default function ChatView({
activePendingProgress?.activeQuestion?.id,
]);
useEffect(() => {
- if (!hasPendingPlanFeedback) {
+ if (!hasPendingPlanFeedback || planSidebarOpen) {
return;
}
- const turnKey =
- activePlanTurnId ?? sidebarProposedPlan?.turnId ?? activeLatestTurn?.turnId ?? null;
- if (!turnKey || planSidebarDismissedForTurnRef.current === turnKey) {
+ if (
+ !planSidebarAutoOpenTurnKey ||
+ planSidebarDismissedForTurnRef.current === planSidebarAutoOpenTurnKey
+ ) {
return;
}
setPlanSidebarOpen(true);
- }, [
- activeLatestTurn?.turnId,
- activePlanTurnId,
- hasPendingPlanFeedback,
- sidebarProposedPlan?.turnId,
- ]);
+ }, [hasPendingPlanFeedback, planSidebarAutoOpenTurnKey, planSidebarOpen]);
useEffect(() => {
attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
diff --git a/apps/web/src/hooks/useFileViewNavigation.test.tsx b/apps/web/src/hooks/useFileViewNavigation.test.tsx
new file mode 100644
index 00000000..84949cd9
--- /dev/null
+++ b/apps/web/src/hooks/useFileViewNavigation.test.tsx
@@ -0,0 +1,182 @@
+import { ProjectId, ThreadId } from "@okcode/contracts";
+import { act, create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { useCodeViewerStore } from "~/codeViewerStore";
+import { useStore } from "~/store";
+import { useFileViewNavigation } from "./useFileViewNavigation";
+
+const navigateMock = vi.fn();
+
+let currentThreadId: string | null = null;
+
+vi.mock("@tanstack/react-router", () => ({
+ useNavigate: () => navigateMock,
+ useParams: ({
+ select,
+ }: {
+ select?: (params: Record) => unknown;
+ } = {}) => {
+ const params = currentThreadId ? { threadId: currentThreadId } : {};
+ return typeof select === "function" ? select(params) : params;
+ },
+}));
+
+const baseStoreState = useStore.getState();
+
+const projectId = ProjectId.makeUnsafe("project-1");
+const firstThreadId = ThreadId.makeUnsafe("thread-1");
+const secondThreadId = ThreadId.makeUnsafe("thread-2");
+
+let renderer: ReactTestRenderer | null = null;
+let latestOpenInViewer: ((cwd: string, relativePath: string) => void) | null = null;
+
+function HookHarness() {
+ latestOpenInViewer = useFileViewNavigation();
+ return null;
+}
+
+function seedThreads() {
+ useStore.setState({
+ projects: [
+ {
+ id: projectId,
+ name: "OK Code",
+ cwd: "/repo/project",
+ model: "gpt-5.4",
+ expanded: true,
+ scripts: [],
+ },
+ ],
+ threads: [
+ {
+ id: firstThreadId,
+ codexThreadId: null,
+ kind: "thread",
+ projectId,
+ title: "First",
+ model: "gpt-5.4",
+ runtimeMode: "full-access",
+ interactionMode: "chat",
+ session: null,
+ messages: [],
+ proposedPlans: [],
+ error: null,
+ createdAt: "2026-05-04T10:00:00.000Z",
+ updatedAt: "2026-05-04T10:00:00.000Z",
+ latestTurn: null,
+ branch: "main",
+ worktreePath: null,
+ turnDiffSummaries: [],
+ activities: [],
+ },
+ {
+ id: secondThreadId,
+ codexThreadId: null,
+ kind: "thread",
+ projectId,
+ title: "Second",
+ model: "gpt-5.4",
+ runtimeMode: "full-access",
+ interactionMode: "chat",
+ session: null,
+ messages: [],
+ proposedPlans: [],
+ error: null,
+ createdAt: "2026-05-04T10:00:01.000Z",
+ updatedAt: "2026-05-04T10:00:01.000Z",
+ latestTurn: null,
+ branch: "main",
+ worktreePath: null,
+ turnDiffSummaries: [],
+ activities: [],
+ },
+ ],
+ threadsHydrated: true,
+ });
+}
+
+async function mountHarness() {
+ await act(async () => {
+ renderer = create();
+ });
+}
+
+async function unmountHarness() {
+ if (!renderer) {
+ return;
+ }
+ await act(async () => {
+ renderer?.unmount();
+ });
+ renderer = null;
+}
+
+beforeEach(() => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ currentThreadId = null;
+ navigateMock.mockReset();
+ latestOpenInViewer = null;
+ seedThreads();
+ useCodeViewerStore.setState({
+ isOpen: false,
+ tabs: [],
+ activeTabId: null,
+ pendingContext: null,
+ });
+});
+
+afterEach(async () => {
+ await unmountHarness();
+ useStore.setState(baseStoreState);
+ currentThreadId = null;
+});
+
+describe("useFileViewNavigation", () => {
+ it("keeps the callback stable across thread snapshot churn", async () => {
+ await mountHarness();
+
+ const firstCallback = latestOpenInViewer;
+ expect(firstCallback).not.toBeNull();
+
+ await act(async () => {
+ useStore.setState((state) => ({
+ threads: state.threads.map((thread) =>
+ thread.id === secondThreadId
+ ? { ...thread, updatedAt: "2026-05-04T10:05:00.000Z" }
+ : { ...thread },
+ ),
+ }));
+ });
+
+ expect(latestOpenInViewer).toBe(firstCallback);
+ });
+
+ it("reads the latest thread list lazily when navigating from outside a thread page", async () => {
+ await mountHarness();
+
+ const initialCallback = latestOpenInViewer;
+ expect(initialCallback).not.toBeNull();
+
+ await act(async () => {
+ useStore.setState((state) => ({
+ threads: state.threads.map((thread) =>
+ thread.id === firstThreadId
+ ? { ...thread, updatedAt: "2026-05-04T09:59:00.000Z" }
+ : thread.id === secondThreadId
+ ? { ...thread, updatedAt: "2026-05-04T10:06:00.000Z" }
+ : { ...thread },
+ ),
+ }));
+ });
+
+ await act(async () => {
+ initialCallback?.("/repo/project", "README.md");
+ });
+
+ expect(navigateMock).toHaveBeenCalledWith({
+ to: "/$threadId",
+ params: { threadId: secondThreadId },
+ });
+ });
+});
diff --git a/apps/web/src/hooks/useFileViewNavigation.ts b/apps/web/src/hooks/useFileViewNavigation.ts
index 45b6a2ca..9524d82a 100644
--- a/apps/web/src/hooks/useFileViewNavigation.ts
+++ b/apps/web/src/hooks/useFileViewNavigation.ts
@@ -14,7 +14,6 @@ export function useFileViewNavigation() {
strict: false,
select: (params) => (params as Record).threadId ?? null,
});
- const threads = useStore((s) => s.threads);
return useCallback(
(cwd: string, relativePath: string) => {
@@ -22,6 +21,7 @@ export function useFileViewNavigation() {
// If not already on a thread page, navigate to the most recent thread
// so the workspace inline sidebar is visible.
if (!threadId) {
+ const threads = useStore.getState().threads;
const sorted = threads.toSorted((a, b) =>
(b.updatedAt ?? b.createdAt).localeCompare(a.updatedAt ?? a.createdAt),
);
@@ -33,6 +33,6 @@ export function useFileViewNavigation() {
}
}
},
- [navigate, openFile, threadId, threads],
+ [navigate, openFile, threadId],
);
}
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index 5c4e0e90..26721acc 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -192,11 +192,16 @@ function isStalePendingRequestFailureDetail(detail: string | undefined): boolean
export function derivePendingApprovals(
activities: ReadonlyArray,
+): PendingApproval[] {
+ return derivePendingApprovalsFromOrderedActivities(orderThreadActivities(activities));
+}
+
+export function derivePendingApprovalsFromOrderedActivities(
+ orderedActivities: ReadonlyArray,
): PendingApproval[] {
const openByRequestId = new Map();
- const ordered = [...activities].toSorted(compareActivitiesByOrder);
- for (const activity of ordered) {
+ for (const activity of orderedActivities) {
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record)
@@ -297,11 +302,16 @@ function parseUserInputQuestions(
export function derivePendingUserInputs(
activities: ReadonlyArray,
+): PendingUserInput[] {
+ return derivePendingUserInputsFromOrderedActivities(orderThreadActivities(activities));
+}
+
+export function derivePendingUserInputsFromOrderedActivities(
+ orderedActivities: ReadonlyArray,
): PendingUserInput[] {
const openByRequestId = new Map();
- const ordered = [...activities].toSorted(compareActivitiesByOrder);
- for (const activity of ordered) {
+ for (const activity of orderedActivities) {
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record)
@@ -348,8 +358,17 @@ export function deriveActivePlanState(
activities: ReadonlyArray,
latestTurnId: TurnId | undefined,
): ActivePlanState | null {
- const ordered = [...activities].toSorted(compareActivitiesByOrder);
- const candidates = ordered.filter((activity) => {
+ return deriveActivePlanStateFromOrderedActivities(
+ orderThreadActivities(activities),
+ latestTurnId,
+ );
+}
+
+export function deriveActivePlanStateFromOrderedActivities(
+ orderedActivities: ReadonlyArray,
+ latestTurnId: TurnId | undefined,
+): ActivePlanState | null {
+ const candidates = orderedActivities.filter((activity) => {
if (activity.kind !== "turn.plan.updated") {
return false;
}
@@ -477,8 +496,14 @@ export function deriveWorkLogEntries(
activities: ReadonlyArray,
latestTurnId: TurnId | undefined,
): WorkLogEntry[] {
- const ordered = [...activities].toSorted(compareActivitiesByOrder);
- const entries = ordered
+ return deriveWorkLogEntriesFromOrderedActivities(orderThreadActivities(activities), latestTurnId);
+}
+
+export function deriveWorkLogEntriesFromOrderedActivities(
+ orderedActivities: ReadonlyArray,
+ latestTurnId: TurnId | undefined,
+): WorkLogEntry[] {
+ const entries = orderedActivities
.filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true))
.filter((activity) => activity.kind !== "tool.started")
.filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed")
@@ -857,6 +882,12 @@ function extractChangedFiles(payload: Record | null): string[]
return changedFiles;
}
+export function orderThreadActivities(
+ activities: ReadonlyArray,
+): OrchestrationThreadActivity[] {
+ return [...activities].toSorted(compareActivitiesByOrder);
+}
+
function compareActivitiesByOrder(
left: OrchestrationThreadActivity,
right: OrchestrationThreadActivity,