|
2 | 2 | import "../index.css"; |
3 | 3 |
|
4 | 4 | import { |
| 5 | + EventId, |
5 | 6 | type GitBranch, |
6 | 7 | ORCHESTRATION_WS_METHODS, |
7 | 8 | type MessageId, |
@@ -40,6 +41,12 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z"; |
40 | 41 | const BASE_TIME_MS = Date.parse(NOW_ISO); |
41 | 42 | const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>"; |
42 | 43 | const ONBOARDING_STORAGE_KEY = "okcode:onboarding-completed:v1"; |
| 44 | +const PLAN_FOLLOW_UP_TURN_ID = "turn-plan-follow-up" as TurnId; |
| 45 | +const PLAN_IMPLEMENTATION_TURN_ID = "turn-plan-implementation" as TurnId; |
| 46 | +const PLAN_FOLLOW_UP_ID = "plan-follow-up-browser"; |
| 47 | +const PLAN_STEP_INSPECT_FILES = "Inspect files"; |
| 48 | +const PLAN_STEP_APPLY_PATCH = "Apply patch"; |
| 49 | +const PLAN_STEP_VERIFY_SIDEBAR = "Verify sidebar stability"; |
43 | 50 |
|
44 | 51 | interface WsRequestEnvelope { |
45 | 52 | id: string; |
@@ -462,6 +469,147 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { |
462 | 469 | }; |
463 | 470 | } |
464 | 471 |
|
| 472 | +function replaceThreadInSnapshot( |
| 473 | + snapshot: OrchestrationReadModel, |
| 474 | + updater: ( |
| 475 | + thread: OrchestrationReadModel["threads"][number], |
| 476 | + ) => OrchestrationReadModel["threads"][number], |
| 477 | +): OrchestrationReadModel { |
| 478 | + const nextThreadIndex = snapshot.threads.findIndex((thread) => thread.id === THREAD_ID); |
| 479 | + if (nextThreadIndex < 0) { |
| 480 | + return snapshot; |
| 481 | + } |
| 482 | + const nextThreads = [...snapshot.threads]; |
| 483 | + nextThreads[nextThreadIndex] = updater(nextThreads[nextThreadIndex]!); |
| 484 | + return { |
| 485 | + ...snapshot, |
| 486 | + threads: nextThreads, |
| 487 | + }; |
| 488 | +} |
| 489 | + |
| 490 | +function createPlanFollowUpSnapshot(): OrchestrationReadModel { |
| 491 | + const snapshot = createSnapshotForTargetUser({ |
| 492 | + targetMessageId: "msg-user-plan-follow-up-target" as MessageId, |
| 493 | + targetText: "plan follow-up thread", |
| 494 | + }); |
| 495 | + const planMarkdown = [ |
| 496 | + "# Stabilize plan implementation flow", |
| 497 | + "", |
| 498 | + `- ${PLAN_STEP_INSPECT_FILES}`, |
| 499 | + `- ${PLAN_STEP_APPLY_PATCH}`, |
| 500 | + `- ${PLAN_STEP_VERIFY_SIDEBAR}`, |
| 501 | + ].join("\n"); |
| 502 | + |
| 503 | + return replaceThreadInSnapshot(snapshot, (thread) => { |
| 504 | + if (!thread.session) { |
| 505 | + return thread; |
| 506 | + } |
| 507 | + return { |
| 508 | + ...thread, |
| 509 | + interactionMode: "plan", |
| 510 | + latestTurn: { |
| 511 | + turnId: PLAN_FOLLOW_UP_TURN_ID, |
| 512 | + state: "completed", |
| 513 | + requestedAt: isoAt(1_100), |
| 514 | + startedAt: isoAt(1_101), |
| 515 | + completedAt: isoAt(1_102), |
| 516 | + assistantMessageId: null, |
| 517 | + }, |
| 518 | + proposedPlans: [ |
| 519 | + { |
| 520 | + id: PLAN_FOLLOW_UP_ID, |
| 521 | + turnId: PLAN_FOLLOW_UP_TURN_ID, |
| 522 | + planMarkdown, |
| 523 | + implementedAt: null, |
| 524 | + implementationThreadId: null, |
| 525 | + createdAt: isoAt(1_100), |
| 526 | + updatedAt: isoAt(1_101), |
| 527 | + }, |
| 528 | + ], |
| 529 | + session: { |
| 530 | + ...thread.session, |
| 531 | + status: "ready", |
| 532 | + activeTurnId: null, |
| 533 | + updatedAt: isoAt(1_102), |
| 534 | + }, |
| 535 | + updatedAt: isoAt(1_102), |
| 536 | + }; |
| 537 | + }); |
| 538 | +} |
| 539 | + |
| 540 | +function createRunningPlanImplementationSnapshot(snapshotSequence: number): OrchestrationReadModel { |
| 541 | + const snapshot = createPlanFollowUpSnapshot(); |
| 542 | + const snapshotWithRunningThread = replaceThreadInSnapshot(snapshot, (thread) => { |
| 543 | + if (!thread.session) { |
| 544 | + return thread; |
| 545 | + } |
| 546 | + const activities: OrchestrationReadModel["threads"][number]["activities"] = [ |
| 547 | + { |
| 548 | + id: EventId.makeUnsafe("evt-plan-implementation-1"), |
| 549 | + tone: "info", |
| 550 | + kind: "turn.plan.updated", |
| 551 | + summary: "Plan updated", |
| 552 | + payload: { |
| 553 | + explanation: "Executing the queued plan steps.", |
| 554 | + plan: [ |
| 555 | + { step: PLAN_STEP_INSPECT_FILES, status: "completed" }, |
| 556 | + { step: PLAN_STEP_APPLY_PATCH, status: "in_progress" }, |
| 557 | + { step: PLAN_STEP_VERIFY_SIDEBAR, status: "pending" }, |
| 558 | + ], |
| 559 | + }, |
| 560 | + turnId: PLAN_IMPLEMENTATION_TURN_ID, |
| 561 | + sequence: 40, |
| 562 | + createdAt: isoAt(1_202), |
| 563 | + }, |
| 564 | + { |
| 565 | + id: EventId.makeUnsafe("evt-plan-implementation-2"), |
| 566 | + tone: "tool", |
| 567 | + kind: "tool.updated", |
| 568 | + summary: "Editing files", |
| 569 | + payload: { |
| 570 | + itemType: "command_execution", |
| 571 | + status: "in_progress", |
| 572 | + title: "Apply patch", |
| 573 | + detail: "Editing files", |
| 574 | + }, |
| 575 | + turnId: PLAN_IMPLEMENTATION_TURN_ID, |
| 576 | + sequence: 41, |
| 577 | + createdAt: isoAt(1_203), |
| 578 | + }, |
| 579 | + ]; |
| 580 | + |
| 581 | + return { |
| 582 | + ...thread, |
| 583 | + interactionMode: "code", |
| 584 | + latestTurn: { |
| 585 | + turnId: PLAN_IMPLEMENTATION_TURN_ID, |
| 586 | + state: "running", |
| 587 | + requestedAt: isoAt(1_200), |
| 588 | + startedAt: isoAt(1_201), |
| 589 | + completedAt: null, |
| 590 | + assistantMessageId: null, |
| 591 | + sourceProposedPlan: { |
| 592 | + threadId: THREAD_ID, |
| 593 | + planId: PLAN_FOLLOW_UP_ID, |
| 594 | + }, |
| 595 | + }, |
| 596 | + activities, |
| 597 | + session: { |
| 598 | + ...thread.session, |
| 599 | + status: "running", |
| 600 | + activeTurnId: PLAN_IMPLEMENTATION_TURN_ID, |
| 601 | + updatedAt: isoAt(1_204), |
| 602 | + }, |
| 603 | + updatedAt: isoAt(1_204), |
| 604 | + }; |
| 605 | + }); |
| 606 | + |
| 607 | + return { |
| 608 | + ...snapshotWithRunningThread, |
| 609 | + snapshotSequence, |
| 610 | + }; |
| 611 | +} |
| 612 | + |
465 | 613 | function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { |
466 | 614 | const tag = body._tag; |
467 | 615 | const fixtureThread = fixture.snapshot.threads.find((thread) => thread.id === THREAD_ID) ?? null; |
@@ -679,6 +827,24 @@ async function waitForSendButton(): Promise<HTMLButtonElement> { |
679 | 827 | ); |
680 | 828 | } |
681 | 829 |
|
| 830 | +async function waitForPlanFollowUpImplementButton(): Promise<HTMLButtonElement> { |
| 831 | + return waitForElement( |
| 832 | + () => |
| 833 | + [...document.querySelectorAll<HTMLButtonElement>('button[type="submit"]')].find( |
| 834 | + (button) => button.textContent?.trim() === "Implement", |
| 835 | + ) ?? null, |
| 836 | + "Unable to find plan implement button.", |
| 837 | + ); |
| 838 | +} |
| 839 | + |
| 840 | +function submitWithButton(button: HTMLButtonElement): void { |
| 841 | + const form = button.closest("form"); |
| 842 | + if (!(form instanceof HTMLFormElement)) { |
| 843 | + throw new Error("Unable to locate composer form for submit button."); |
| 844 | + } |
| 845 | + form.requestSubmit(button); |
| 846 | +} |
| 847 | + |
682 | 848 | function isVisibleElement(element: Element | null): element is HTMLElement { |
683 | 849 | return ( |
684 | 850 | element instanceof HTMLElement && |
@@ -910,6 +1076,15 @@ async function mountChatView(options: { |
910 | 1076 | }; |
911 | 1077 | } |
912 | 1078 |
|
| 1079 | +async function syncFixtureSnapshot(snapshot: OrchestrationReadModel): Promise<void> { |
| 1080 | + fixture = { |
| 1081 | + ...fixture, |
| 1082 | + snapshot, |
| 1083 | + }; |
| 1084 | + useStore.getState().syncServerReadModel(snapshot); |
| 1085 | + await waitForLayout(); |
| 1086 | +} |
| 1087 | + |
913 | 1088 | async function measureUserRowAtViewport(options: { |
914 | 1089 | snapshot: OrchestrationReadModel; |
915 | 1090 | targetMessageId: MessageId; |
@@ -1006,6 +1181,141 @@ describe("ChatView timeline estimator parity (full app)", () => { |
1006 | 1181 | } |
1007 | 1182 | }); |
1008 | 1183 |
|
| 1184 | + it("implements a proposed plan on the same thread without tripping a render loop", async () => { |
| 1185 | + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); |
| 1186 | + const mounted = await mountChatView({ |
| 1187 | + viewport: DEFAULT_VIEWPORT, |
| 1188 | + snapshot: createPlanFollowUpSnapshot(), |
| 1189 | + }); |
| 1190 | + |
| 1191 | + try { |
| 1192 | + await waitForServerConfigToApply(); |
| 1193 | + await vi.waitFor(async () => { |
| 1194 | + expect(await readCurrentInteractionModeLabel()).toBe("Plan"); |
| 1195 | + }); |
| 1196 | + |
| 1197 | + const sendButton = await waitForPlanFollowUpImplementButton(); |
| 1198 | + await vi.waitFor(() => { |
| 1199 | + expect(sendButton.disabled).toBe(false); |
| 1200 | + }); |
| 1201 | + submitWithButton(sendButton); |
| 1202 | + |
| 1203 | + await vi.waitFor(() => { |
| 1204 | + const request = wsRequests.find( |
| 1205 | + (entry) => |
| 1206 | + entry._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && |
| 1207 | + typeof entry.command === "object" && |
| 1208 | + entry.command !== null && |
| 1209 | + "type" in entry.command && |
| 1210 | + entry.command.type === "thread.turn.start" && |
| 1211 | + "threadId" in entry.command && |
| 1212 | + entry.command.threadId === THREAD_ID, |
| 1213 | + ); |
| 1214 | + expect(request).toMatchObject({ |
| 1215 | + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, |
| 1216 | + command: { |
| 1217 | + type: "thread.turn.start", |
| 1218 | + threadId: THREAD_ID, |
| 1219 | + interactionMode: "code", |
| 1220 | + sourceProposedPlan: { |
| 1221 | + threadId: THREAD_ID, |
| 1222 | + planId: PLAN_FOLLOW_UP_ID, |
| 1223 | + }, |
| 1224 | + }, |
| 1225 | + }); |
| 1226 | + }); |
| 1227 | + |
| 1228 | + await vi.waitFor(async () => { |
| 1229 | + expect(await readCurrentInteractionModeLabel()).toBe("Code"); |
| 1230 | + }); |
| 1231 | + |
| 1232 | + await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(2)); |
| 1233 | + |
| 1234 | + await vi.waitFor( |
| 1235 | + () => { |
| 1236 | + expect( |
| 1237 | + document.querySelector<HTMLButtonElement>('button[aria-label="Close plan sidebar"]'), |
| 1238 | + ).toBeTruthy(); |
| 1239 | + expect(document.body.textContent).toContain(PLAN_STEP_INSPECT_FILES); |
| 1240 | + expect(document.body.textContent).toContain(PLAN_STEP_APPLY_PATCH); |
| 1241 | + }, |
| 1242 | + { timeout: 8_000, interval: 16 }, |
| 1243 | + ); |
| 1244 | + |
| 1245 | + expect( |
| 1246 | + consoleErrorSpy.mock.calls.some((call) => |
| 1247 | + call.some( |
| 1248 | + (value) => |
| 1249 | + typeof value === "string" && |
| 1250 | + (value.includes("Too many re-renders") || |
| 1251 | + value.includes("Minified React error #301")), |
| 1252 | + ), |
| 1253 | + ), |
| 1254 | + ).toBe(false); |
| 1255 | + } finally { |
| 1256 | + consoleErrorSpy.mockRestore(); |
| 1257 | + await mounted.cleanup(); |
| 1258 | + } |
| 1259 | + }); |
| 1260 | + |
| 1261 | + it("keeps the same-thread implementation surface stable across repeated identical snapshots", async () => { |
| 1262 | + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); |
| 1263 | + const mounted = await mountChatView({ |
| 1264 | + viewport: DEFAULT_VIEWPORT, |
| 1265 | + snapshot: createPlanFollowUpSnapshot(), |
| 1266 | + }); |
| 1267 | + |
| 1268 | + try { |
| 1269 | + await waitForServerConfigToApply(); |
| 1270 | + const sendButton = await waitForPlanFollowUpImplementButton(); |
| 1271 | + await vi.waitFor(() => { |
| 1272 | + expect(sendButton.disabled).toBe(false); |
| 1273 | + }); |
| 1274 | + submitWithButton(sendButton); |
| 1275 | + |
| 1276 | + await vi.waitFor(() => { |
| 1277 | + const request = wsRequests.find( |
| 1278 | + (entry) => |
| 1279 | + entry._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && |
| 1280 | + typeof entry.command === "object" && |
| 1281 | + entry.command !== null && |
| 1282 | + "type" in entry.command && |
| 1283 | + entry.command.type === "thread.turn.start" && |
| 1284 | + "threadId" in entry.command && |
| 1285 | + entry.command.threadId === THREAD_ID, |
| 1286 | + ); |
| 1287 | + expect(request).toBeTruthy(); |
| 1288 | + }); |
| 1289 | + |
| 1290 | + await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(2)); |
| 1291 | + await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(3)); |
| 1292 | + await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(4)); |
| 1293 | + |
| 1294 | + await vi.waitFor( |
| 1295 | + () => { |
| 1296 | + expect(document.body.textContent).toContain(PLAN_STEP_VERIFY_SIDEBAR); |
| 1297 | + expect(document.body.textContent).not.toContain("Plan ready"); |
| 1298 | + expect(document.querySelectorAll('[aria-label="Close plan sidebar"]')).toHaveLength(1); |
| 1299 | + }, |
| 1300 | + { timeout: 8_000, interval: 16 }, |
| 1301 | + ); |
| 1302 | + |
| 1303 | + expect( |
| 1304 | + consoleErrorSpy.mock.calls.some((call) => |
| 1305 | + call.some( |
| 1306 | + (value) => |
| 1307 | + typeof value === "string" && |
| 1308 | + (value.includes("Too many re-renders") || |
| 1309 | + value.includes("Minified React error #301")), |
| 1310 | + ), |
| 1311 | + ), |
| 1312 | + ).toBe(false); |
| 1313 | + } finally { |
| 1314 | + consoleErrorSpy.mockRestore(); |
| 1315 | + await mounted.cleanup(); |
| 1316 | + } |
| 1317 | + }); |
| 1318 | + |
1009 | 1319 | it.each(TEXT_VIEWPORT_MATRIX)( |
1010 | 1320 | "keeps long user message estimate close at the $name viewport", |
1011 | 1321 | async (viewport) => { |
|
0 commit comments