| author | |
| committer | |
| log | 640a2cca9f9effc434d1296d7b625ee09bcba64e |
| tree | 632727f823634dbb3708fecf821b34b242bf7ee8 |
| parent | 12edc3455cf675d2e8000f508b38b79bc7743a88 |
- Add `thread.proposed-plan-upserted` orchestration flow from ingestion through decider/projector
- Store proposed plans in a dedicated projection table and include them in snapshot queries
- Update provider runtime/web handling and tests to use proposed plans instead of assistant message wrappers28 files changed, 1113 insertions(+), 323 deletions(-)
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+1| ... | ... | @@ -61,6 +61,7 @@ function makeSnapshot(input: { |
| 61 | 61 | deletedAt: null, |
| 62 | 62 | messages: [], |
| 63 | 63 | activities: [], |
| 64 | proposedPlans: [], | |
| 64 | 65 | checkpoints: [ |
| 65 | 66 | { |
| 66 | 67 | turnId: TurnId.makeUnsafe("turn-1"), |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+84| ... | ... | @@ -18,6 +18,10 @@ import { |
| 18 | 18 | type ProjectionThreadMessage, |
| 19 | 19 | ProjectionThreadMessageRepository, |
| 20 | 20 | } from "../../persistence/Services/ProjectionThreadMessages.ts"; |
| 21 | import { | |
| 22 | type ProjectionThreadProposedPlan, | |
| 23 | ProjectionThreadProposedPlanRepository, | |
| 24 | } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; | |
| 21 | 25 | import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; |
| 22 | 26 | import { |
| 23 | 27 | type ProjectionTurn, |
| ... | ... | @@ -29,6 +33,7 @@ import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/Projec |
| 29 | 33 | import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts"; |
| 30 | 34 | import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; |
| 31 | 35 | import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; |
| 36 | import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; | |
| 32 | 37 | import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; |
| 33 | 38 | import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; |
| 34 | 39 | import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; |
| ... | ... | @@ -48,6 +53,7 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { |
| 48 | 53 | projects: "projection.projects", |
| 49 | 54 | threads: "projection.threads", |
| 50 | 55 | threadMessages: "projection.thread-messages", |
| 56 | threadProposedPlans: "projection.thread-proposed-plans", | |
| 51 | 57 | threadActivities: "projection.thread-activities", |
| 52 | 58 | threadSessions: "projection.thread-sessions", |
| 53 | 59 | threadTurns: "projection.thread-turns", |
| ... | ... | @@ -189,6 +195,26 @@ function retainProjectionActivitiesAfterRevert( |
| 189 | 195 | ); |
| 190 | 196 | } |
| 191 | 197 | |
| 198 | function retainProjectionProposedPlansAfterRevert( | |
| 199 | proposedPlans: ReadonlyArray<ProjectionThreadProposedPlan>, | |
| 200 | turns: ReadonlyArray<ProjectionTurn>, | |
| 201 | turnCount: number, | |
| 202 | ): ReadonlyArray<ProjectionThreadProposedPlan> { | |
| 203 | const retainedTurnIds = new Set<string>( | |
| 204 | turns | |
| 205 | .filter( | |
| 206 | (turn) => | |
| 207 | turn.turnId !== null && | |
| 208 | turn.checkpointTurnCount !== null && | |
| 209 | turn.checkpointTurnCount <= turnCount, | |
| 210 | ) | |
| 211 | .flatMap((turn) => (turn.turnId === null ? [] : [turn.turnId])), | |
| 212 | ); | |
| 213 | return proposedPlans.filter( | |
| 214 | (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), | |
| 215 | ); | |
| 216 | } | |
| 217 | ||
| 192 | 218 | function collectThreadAttachmentRelativePaths( |
| 193 | 219 | threadId: string, |
| 194 | 220 | messages: ReadonlyArray<ProjectionThreadMessage>, |
| ... | ... | @@ -320,6 +346,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 320 | 346 | const projectionProjectRepository = yield* ProjectionProjectRepository; |
| 321 | 347 | const projectionThreadRepository = yield* ProjectionThreadRepository; |
| 322 | 348 | const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; |
| 349 | const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; | |
| 323 | 350 | const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; |
| 324 | 351 | const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; |
| 325 | 352 | const projectionTurnRepository = yield* ProjectionTurnRepository; |
| ... | ... | @@ -474,6 +501,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 474 | 501 | } |
| 475 | 502 | |
| 476 | 503 | case "thread.message-sent": |
| 504 | case "thread.proposed-plan-upserted": | |
| 477 | 505 | case "thread.activity-appended": { |
| 478 | 506 | const existingRow = yield* projectionThreadRepository.getById({ |
| 479 | 507 | threadId: event.payload.threadId, |
| ... | ... | @@ -615,6 +643,57 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 615 | 643 | } |
| 616 | 644 | }); |
| 617 | 645 | |
| 646 | const applyThreadProposedPlansProjection: ProjectorDefinition["apply"] = ( | |
| 647 | event, | |
| 648 | _attachmentSideEffects, | |
| 649 | ) => | |
| 650 | Effect.gen(function* () { | |
| 651 | switch (event.type) { | |
| 652 | case "thread.proposed-plan-upserted": | |
| 653 | yield* projectionThreadProposedPlanRepository.upsert({ | |
| 654 | planId: event.payload.proposedPlan.id, | |
| 655 | threadId: event.payload.threadId, | |
| 656 | turnId: event.payload.proposedPlan.turnId, | |
| 657 | planMarkdown: event.payload.proposedPlan.planMarkdown, | |
| 658 | createdAt: event.payload.proposedPlan.createdAt, | |
| 659 | updatedAt: event.payload.proposedPlan.updatedAt, | |
| 660 | }); | |
| 661 | return; | |
| 662 | ||
| 663 | case "thread.reverted": { | |
| 664 | const existingRows = yield* projectionThreadProposedPlanRepository.listByThreadId({ | |
| 665 | threadId: event.payload.threadId, | |
| 666 | }); | |
| 667 | if (existingRows.length === 0) { | |
| 668 | return; | |
| 669 | } | |
| 670 | ||
| 671 | const existingTurns = yield* projectionTurnRepository.listByThreadId({ | |
| 672 | threadId: event.payload.threadId, | |
| 673 | }); | |
| 674 | const keptRows = retainProjectionProposedPlansAfterRevert( | |
| 675 | existingRows, | |
| 676 | existingTurns, | |
| 677 | event.payload.turnCount, | |
| 678 | ); | |
| 679 | if (keptRows.length === existingRows.length) { | |
| 680 | return; | |
| 681 | } | |
| 682 | ||
| 683 | yield* projectionThreadProposedPlanRepository.deleteByThreadId({ | |
| 684 | threadId: event.payload.threadId, | |
| 685 | }); | |
| 686 | yield* Effect.forEach(keptRows, projectionThreadProposedPlanRepository.upsert, { | |
| 687 | concurrency: 1, | |
| 688 | }).pipe(Effect.asVoid); | |
| 689 | return; | |
| 690 | } | |
| 691 | ||
| 692 | default: | |
| 693 | return; | |
| 694 | } | |
| 695 | }); | |
| 696 | ||
| 618 | 697 | const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = ( |
| 619 | 698 | event, |
| 620 | 699 | _attachmentSideEffects, |
| ... | ... | @@ -1023,6 +1102,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 1023 | 1102 | name: ORCHESTRATION_PROJECTOR_NAMES.threadMessages, |
| 1024 | 1103 | apply: applyThreadMessagesProjection, |
| 1025 | 1104 | }, |
| 1105 | { | |
| 1106 | name: ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans, | |
| 1107 | apply: applyThreadProposedPlansProjection, | |
| 1108 | }, | |
| 1026 | 1109 | { |
| 1027 | 1110 | name: ORCHESTRATION_PROJECTOR_NAMES.threadActivities, |
| 1028 | 1111 | apply: applyThreadActivitiesProjection, |
| ... | ... | @@ -1142,6 +1225,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( |
| 1142 | 1225 | Layer.provideMerge(ProjectionProjectRepositoryLive), |
| 1143 | 1226 | Layer.provideMerge(ProjectionThreadRepositoryLive), |
| 1144 | 1227 | Layer.provideMerge(ProjectionThreadMessageRepositoryLive), |
| 1228 | Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive), | |
| 1145 | 1229 | Layer.provideMerge(ProjectionThreadActivityRepositoryLive), |
| 1146 | 1230 | Layer.provideMerge(ProjectionThreadSessionRepositoryLive), |
| 1147 | 1231 | Layer.provideMerge(ProjectionTurnRepositoryLive), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1| ... | ... | @@ -260,6 +260,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 260 | 260 | updatedAt: "2026-02-24T00:00:05.000Z", |
| 261 | 261 | }, |
| 262 | 262 | ], |
| 263 | proposedPlans: [], | |
| 263 | 264 | activities: [ |
| 264 | 265 | { |
| 265 | 266 | id: asEventId("activity-1"), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+45| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | type OrchestrationCheckpointSummary, |
| 11 | 11 | type OrchestrationLatestTurn, |
| 12 | 12 | type OrchestrationMessage, |
| 13 | type OrchestrationProposedPlan, | |
| 13 | 14 | type OrchestrationProject, |
| 14 | 15 | type OrchestrationSession, |
| 15 | 16 | type OrchestrationThread, |
| ... | ... | @@ -30,6 +31,7 @@ import { ProjectionProject } from "../../persistence/Services/ProjectionProjects |
| 30 | 31 | import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; |
| 31 | 32 | import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; |
| 32 | 33 | import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; |
| 34 | import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; | |
| 33 | 35 | import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; |
| 34 | 36 | import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; |
| 35 | 37 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| ... | ... | @@ -50,6 +52,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( |
| 50 | 52 | attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), |
| 51 | 53 | }), |
| 52 | 54 | ); |
| 55 | const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; | |
| 53 | 56 | const ProjectionThreadDbRowSchema = ProjectionThread; |
| 54 | 57 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( |
| 55 | 58 | Struct.assign({ |
| ... | ... | @@ -78,6 +81,7 @@ const REQUIRED_SNAPSHOT_PROJECTORS = [ |
| 78 | 81 | ORCHESTRATION_PROJECTOR_NAMES.projects, |
| 79 | 82 | ORCHESTRATION_PROJECTOR_NAMES.threads, |
| 80 | 83 | ORCHESTRATION_PROJECTOR_NAMES.threadMessages, |
| 84 | ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans, | |
| 81 | 85 | ORCHESTRATION_PROJECTOR_NAMES.threadActivities, |
| 82 | 86 | ORCHESTRATION_PROJECTOR_NAMES.threadSessions, |
| 83 | 87 | ORCHESTRATION_PROJECTOR_NAMES.checkpoints, |
| ... | ... | @@ -186,6 +190,23 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 186 | 190 | `, |
| 187 | 191 | }); |
| 188 | 192 | |
| 193 | const listThreadProposedPlanRows = SqlSchema.findAll({ | |
| 194 | Request: Schema.Void, | |
| 195 | Result: ProjectionThreadProposedPlanDbRowSchema, | |
| 196 | execute: () => | |
| 197 | sql` | |
| 198 | SELECT | |
| 199 | plan_id AS "planId", | |
| 200 | thread_id AS "threadId", | |
| 201 | turn_id AS "turnId", | |
| 202 | plan_markdown AS "planMarkdown", | |
| 203 | created_at AS "createdAt", | |
| 204 | updated_at AS "updatedAt" | |
| 205 | FROM projection_thread_proposed_plans | |
| 206 | ORDER BY thread_id ASC, created_at ASC, plan_id ASC | |
| 207 | `, | |
| 208 | }); | |
| 209 | ||
| 189 | 210 | const listThreadActivityRows = SqlSchema.findAll({ |
| 190 | 211 | Request: Schema.Void, |
| 191 | 212 | Result: ProjectionThreadActivityDbRowSchema, |
| ... | ... | @@ -291,6 +312,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 291 | 312 | projectRows, |
| 292 | 313 | threadRows, |
| 293 | 314 | messageRows, |
| 315 | proposedPlanRows, | |
| 294 | 316 | activityRows, |
| 295 | 317 | sessionRows, |
| 296 | 318 | checkpointRows, |
| ... | ... | @@ -321,6 +343,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 321 | 343 | ), |
| 322 | 344 | ), |
| 323 | 345 | ), |
| 346 | listThreadProposedPlanRows(undefined).pipe( | |
| 347 | Effect.mapError( | |
| 348 | toPersistenceSqlOrDecodeError( | |
| 349 | "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query", | |
| 350 | "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows", | |
| 351 | ), | |
| 352 | ), | |
| 353 | ), | |
| 324 | 354 | listThreadActivityRows(undefined).pipe( |
| 325 | 355 | Effect.mapError( |
| 326 | 356 | toPersistenceSqlOrDecodeError( |
| ... | ... | @@ -364,6 +394,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 364 | 394 | ]); |
| 365 | 395 | |
| 366 | 396 | const messagesByThread = new Map<string, Array<OrchestrationMessage>>(); |
| 397 | const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>(); | |
| 367 | 398 | const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>(); |
| 368 | 399 | const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>(); |
| 369 | 400 | const sessionsByThread = new Map<string, OrchestrationSession>(); |
| ... | ... | @@ -397,6 +428,19 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 397 | 428 | messagesByThread.set(row.threadId, threadMessages); |
| 398 | 429 | } |
| 399 | 430 | |
| 431 | for (const row of proposedPlanRows) { | |
| 432 | updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 433 | const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? []; | |
| 434 | threadProposedPlans.push({ | |
| 435 | id: row.planId, | |
| 436 | turnId: row.turnId, | |
| 437 | planMarkdown: row.planMarkdown, | |
| 438 | createdAt: row.createdAt, | |
| 439 | updatedAt: row.updatedAt, | |
| 440 | }); | |
| 441 | proposedPlansByThread.set(row.threadId, threadProposedPlans); | |
| 442 | } | |
| 443 | ||
| 400 | 444 | for (const row of activityRows) { |
| 401 | 445 | updatedAt = maxIso(updatedAt, row.createdAt); |
| 402 | 446 | const threadActivities = activitiesByThread.get(row.threadId) ?? []; |
| ... | ... | @@ -494,6 +538,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 494 | 538 | updatedAt: row.updatedAt, |
| 495 | 539 | deletedAt: row.deletedAt, |
| 496 | 540 | messages: messagesByThread.get(row.threadId) ?? [], |
| 541 | proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], | |
| 497 | 542 | activities: activitiesByThread.get(row.threadId) ?? [], |
| 498 | 543 | checkpoints: checkpointsByThread.get(row.threadId) ?? [], |
| 499 | 544 | session: sessionsByThread.get(row.threadId) ?? null, |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+43-35| ... | ... | @@ -103,6 +103,7 @@ async function waitForThread( |
| 103 | 103 | type ProviderRuntimeTestReadModel = OrchestrationReadModel; |
| 104 | 104 | type ProviderRuntimeTestThread = ProviderRuntimeTestReadModel["threads"][number]; |
| 105 | 105 | type ProviderRuntimeTestMessage = ProviderRuntimeTestThread["messages"][number]; |
| 106 | type ProviderRuntimeTestProposedPlan = ProviderRuntimeTestThread["proposedPlans"][number]; | |
| 106 | 107 | type ProviderRuntimeTestActivity = ProviderRuntimeTestThread["activities"][number]; |
| 107 | 108 | type ProviderRuntimeTestCheckpoint = ProviderRuntimeTestThread["checkpoints"][number]; |
| 108 | 109 | |
| ... | ... | @@ -633,41 +634,35 @@ describe("ProviderRuntimeIngestion", () => { |
| 633 | 634 | expect(message?.streaming).toBe(false); |
| 634 | 635 | }); |
| 635 | 636 | |
| 636 | it("projects completed plan items into finalized proposed-plan assistant messages", async () => { | |
| 637 | it("projects completed plan items into first-class proposed plans", async () => { | |
| 637 | 638 | const harness = await createHarness(); |
| 638 | 639 | const now = new Date().toISOString(); |
| 639 | 640 | |
| 640 | 641 | harness.emit({ |
| 641 | type: "item.completed", | |
| 642 | type: "turn.proposed.completed", | |
| 642 | 643 | eventId: asEventId("evt-plan-item-completed"), |
| 643 | 644 | provider: "codex", |
| 644 | 645 | createdAt: now, |
| 645 | 646 | threadId: asThreadId("thread-1"), |
| 646 | 647 | turnId: asTurnId("turn-plan-final"), |
| 647 | itemId: asItemId("item-plan-final"), | |
| 648 | 648 | payload: { |
| 649 | itemType: "plan", | |
| 650 | status: "completed", | |
| 651 | detail: "## Ship plan\n\n- wire projection\n- render follow-up", | |
| 649 | planMarkdown: "## Ship plan\n\n- wire projection\n- render follow-up", | |
| 652 | 650 | }, |
| 653 | 651 | }); |
| 654 | 652 | |
| 655 | const thread = await waitForThread(harness.engine, (entry) => { | |
| 656 | const message = entry.messages.find( | |
| 657 | (candidate: ProviderRuntimeTestMessage) => candidate.id === "assistant:item-plan-final", | |
| 658 | ); | |
| 659 | return message?.streaming === false && message.text.includes("<proposed_plan>"); | |
| 660 | }); | |
| 661 | const message = thread.messages.find( | |
| 662 | (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-plan-final", | |
| 653 | const thread = await waitForThread(harness.engine, (entry) => | |
| 654 | entry.proposedPlans.some( | |
| 655 | (proposedPlan: ProviderRuntimeTestProposedPlan) => | |
| 656 | proposedPlan.id === "plan:thread-1:turn:turn-plan-final", | |
| 657 | ), | |
| 663 | 658 | ); |
| 664 | expect(message?.text).toBe( | |
| 665 | "<proposed_plan>\n## Ship plan\n\n- wire projection\n- render follow-up\n</proposed_plan>", | |
| 659 | const proposedPlan = thread.proposedPlans.find( | |
| 660 | (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-plan-final", | |
| 666 | 661 | ); |
| 667 | expect(message?.streaming).toBe(false); | |
| 662 | expect(proposedPlan?.planMarkdown).toBe("## Ship plan\n\n- wire projection\n- render follow-up"); | |
| 668 | 663 | }); |
| 669 | 664 | |
| 670 | it("finalizes buffered plan deltas into a proposed-plan assistant message on turn completion", async () => { | |
| 665 | it("finalizes buffered proposed-plan deltas into a first-class proposed plan on turn completion", async () => { | |
| 671 | 666 | const harness = await createHarness(); |
| 672 | 667 | const now = new Date().toISOString(); |
| 673 | 668 | |
| ... | ... | @@ -687,28 +682,24 @@ describe("ProviderRuntimeIngestion", () => { |
| 687 | 682 | ); |
| 688 | 683 | |
| 689 | 684 | harness.emit({ |
| 690 | type: "content.delta", | |
| 685 | type: "turn.proposed.delta", | |
| 691 | 686 | eventId: asEventId("evt-plan-delta-1"), |
| 692 | 687 | provider: "codex", |
| 693 | 688 | createdAt: now, |
| 694 | 689 | threadId: asThreadId("thread-1"), |
| 695 | 690 | turnId: asTurnId("turn-plan-buffer"), |
| 696 | itemId: asItemId("item-plan-buffer"), | |
| 697 | 691 | payload: { |
| 698 | streamKind: "plan_text", | |
| 699 | 692 | delta: "## Buffered plan\n\n- first", |
| 700 | 693 | }, |
| 701 | 694 | }); |
| 702 | 695 | harness.emit({ |
| 703 | type: "content.delta", | |
| 696 | type: "turn.proposed.delta", | |
| 704 | 697 | eventId: asEventId("evt-plan-delta-2"), |
| 705 | 698 | provider: "codex", |
| 706 | 699 | createdAt: now, |
| 707 | 700 | threadId: asThreadId("thread-1"), |
| 708 | 701 | turnId: asTurnId("turn-plan-buffer"), |
| 709 | itemId: asItemId("item-plan-buffer"), | |
| 710 | 702 | payload: { |
| 711 | streamKind: "plan_text", | |
| 712 | 703 | delta: "\n- second", |
| 713 | 704 | }, |
| 714 | 705 | }); |
| ... | ... | @@ -724,19 +715,16 @@ describe("ProviderRuntimeIngestion", () => { |
| 724 | 715 | }, |
| 725 | 716 | }); |
| 726 | 717 | |
| 727 | const thread = await waitForThread(harness.engine, (entry) => { | |
| 728 | const message = entry.messages.find( | |
| 729 | (candidate: ProviderRuntimeTestMessage) => candidate.id === "assistant:item-plan-buffer", | |
| 730 | ); | |
| 731 | return message?.streaming === false && message.text.includes("<proposed_plan>"); | |
| 732 | }); | |
| 733 | const message = thread.messages.find( | |
| 734 | (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-plan-buffer", | |
| 718 | const thread = await waitForThread(harness.engine, (entry) => | |
| 719 | entry.proposedPlans.some( | |
| 720 | (proposedPlan: ProviderRuntimeTestProposedPlan) => | |
| 721 | proposedPlan.id === "plan:thread-1:turn:turn-plan-buffer", | |
| 722 | ), | |
| 735 | 723 | ); |
| 736 | expect(message?.text).toBe( | |
| 737 | "<proposed_plan>\n## Buffered plan\n\n- first\n- second\n</proposed_plan>", | |
| 724 | const proposedPlan = thread.proposedPlans.find( | |
| 725 | (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-plan-buffer", | |
| 738 | 726 | ); |
| 739 | expect(message?.streaming).toBe(false); | |
| 727 | expect(proposedPlan?.planMarkdown).toBe("## Buffered plan\n\n- first\n- second"); | |
| 740 | 728 | }); |
| 741 | 729 | |
| 742 | 730 | it("buffers assistant deltas by default until completion", async () => { |
| ... | ... | @@ -1370,12 +1358,27 @@ describe("ProviderRuntimeIngestion", () => { |
| 1370 | 1358 | summary: "<proposed_plan>\n# Plan title\n</proposed_plan>", |
| 1371 | 1359 | }, |
| 1372 | 1360 | }); |
| 1361 | harness.emit({ | |
| 1362 | type: "turn.proposed.completed", | |
| 1363 | eventId: asEventId("evt-task-proposed-plan-completed"), | |
| 1364 | provider: "codex", | |
| 1365 | createdAt: now, | |
| 1366 | threadId: asThreadId("thread-1"), | |
| 1367 | turnId: asTurnId("turn-task-1"), | |
| 1368 | payload: { | |
| 1369 | planMarkdown: "# Plan title", | |
| 1370 | }, | |
| 1371 | }); | |
| 1373 | 1372 | |
| 1374 | 1373 | const thread = await waitForThread( |
| 1375 | 1374 | harness.engine, |
| 1376 | 1375 | (entry) => |
| 1377 | 1376 | entry.activities.some( |
| 1378 | 1377 | (activity: ProviderRuntimeTestActivity) => activity.kind === "task.completed", |
| 1378 | ) && | |
| 1379 | entry.proposedPlans.some( | |
| 1380 | (proposedPlan: ProviderRuntimeTestProposedPlan) => | |
| 1381 | proposedPlan.id === "plan:thread-1:turn:turn-task-1", | |
| 1379 | 1382 | ), |
| 1380 | 1383 | ); |
| 1381 | 1384 | |
| ... | ... | @@ -1406,6 +1409,11 @@ describe("ProviderRuntimeIngestion", () => { |
| 1406 | 1409 | ); |
| 1407 | 1410 | expect(completed?.kind).toBe("task.completed"); |
| 1408 | 1411 | expect(completedPayload?.detail).toBe("<proposed_plan>\n# Plan title\n</proposed_plan>"); |
| 1412 | expect( | |
| 1413 | thread.proposedPlans.find( | |
| 1414 | (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-task-1", | |
| 1415 | )?.planMarkdown, | |
| 1416 | ).toBe("# Plan title"); | |
| 1409 | 1417 | }); |
| 1410 | 1418 | |
| 1411 | 1419 | it("projects structured user input request and resolution as thread activities", async () => { |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+172-47| ... | ... | @@ -26,10 +26,10 @@ const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId |
| 26 | 26 | const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered"; |
| 27 | 27 | const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; |
| 28 | 28 | const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); |
| 29 | const MESSAGE_STREAM_KIND_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; | |
| 30 | const MESSAGE_STREAM_KIND_BY_MESSAGE_ID_TTL = Duration.minutes(120); | |
| 31 | 29 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; |
| 32 | 30 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); |
| 31 | const BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY = 10_000; | |
| 32 | const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); | |
| 33 | 33 | const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; |
| 34 | 34 | const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; |
| 35 | 35 | |
| ... | ... | @@ -67,12 +67,27 @@ function truncateDetail(value: string, limit = 180): string { |
| 67 | 67 | return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | function wrapProposedPlanMessage(planMarkdown: string | undefined): string | undefined { | |
| 70 | function normalizeProposedPlanMarkdown(planMarkdown: string | undefined): string | undefined { | |
| 71 | 71 | const trimmed = planMarkdown?.trim(); |
| 72 | 72 | if (!trimmed) { |
| 73 | 73 | return undefined; |
| 74 | 74 | } |
| 75 | return `<proposed_plan>\n${trimmed}\n</proposed_plan>`; | |
| 75 | return trimmed; | |
| 76 | } | |
| 77 | ||
| 78 | function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string { | |
| 79 | return `plan:${threadId}:turn:${turnId}`; | |
| 80 | } | |
| 81 | ||
| 82 | function proposedPlanIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId): string { | |
| 83 | const turnId = toTurnId(event.turnId); | |
| 84 | if (turnId) { | |
| 85 | return proposedPlanIdForTurn(threadId, turnId); | |
| 86 | } | |
| 87 | if (event.itemId) { | |
| 88 | return `plan:${threadId}:item:${event.itemId}`; | |
| 89 | } | |
| 90 | return `plan:${threadId}:event:${event.eventId}`; | |
| 76 | 91 | } |
| 77 | 92 | |
| 78 | 93 | function asString(value: unknown): string | undefined { |
| ... | ... | @@ -480,18 +495,18 @@ const make = Effect.gen(function* () { |
| 480 | 495 | lookup: () => Effect.succeed(new Set<MessageId>()), |
| 481 | 496 | }); |
| 482 | 497 | |
| 483 | const messageStreamKindByMessageId = yield* Cache.make<MessageId, "assistant_text" | "plan_text">({ | |
| 484 | capacity: MESSAGE_STREAM_KIND_BY_MESSAGE_ID_CACHE_CAPACITY, | |
| 485 | timeToLive: MESSAGE_STREAM_KIND_BY_MESSAGE_ID_TTL, | |
| 486 | lookup: () => Effect.succeed("assistant_text"), | |
| 487 | }); | |
| 488 | ||
| 489 | 498 | const bufferedAssistantTextByMessageId = yield* Cache.make<MessageId, string>({ |
| 490 | 499 | capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, |
| 491 | 500 | timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, |
| 492 | 501 | lookup: () => Effect.succeed(""), |
| 493 | 502 | }); |
| 494 | 503 | |
| 504 | const bufferedProposedPlanById = yield* Cache.make<string, { text: string; createdAt: string }>({ | |
| 505 | capacity: BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY, | |
| 506 | timeToLive: BUFFERED_PROPOSED_PLAN_BY_ID_TTL, | |
| 507 | lookup: () => Effect.succeed({ text: "", createdAt: "" }), | |
| 508 | }); | |
| 509 | ||
| 495 | 510 | const rememberAssistantMessageId = ( |
| 496 | 511 | threadId: ThreadId, |
| 497 | 512 | turnId: TurnId, |
| ... | ... | @@ -545,16 +560,6 @@ const make = Effect.gen(function* () { |
| 545 | 560 | const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) => |
| 546 | 561 | Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); |
| 547 | 562 | |
| 548 | const rememberMessageStreamKind = ( | |
| 549 | messageId: MessageId, | |
| 550 | streamKind: "assistant_text" | "plan_text", | |
| 551 | ) => Cache.set(messageStreamKindByMessageId, messageId, streamKind); | |
| 552 | ||
| 553 | const getMessageStreamKind = (messageId: MessageId) => | |
| 554 | Cache.getOption(messageStreamKindByMessageId, messageId).pipe( | |
| 555 | Effect.map((streamKind) => Option.getOrElse(streamKind, () => "assistant_text" as const)), | |
| 556 | ); | |
| 557 | ||
| 558 | 563 | const appendBufferedAssistantText = (messageId: MessageId, delta: string) => |
| 559 | 564 | Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( |
| 560 | 565 | Effect.flatMap((existingText) => |
| ... | ... | @@ -587,11 +592,30 @@ const make = Effect.gen(function* () { |
| 587 | 592 | const clearBufferedAssistantText = (messageId: MessageId) => |
| 588 | 593 | Cache.invalidate(bufferedAssistantTextByMessageId, messageId); |
| 589 | 594 | |
| 590 | const clearAssistantMessageState = (messageId: MessageId) => | |
| 591 | Effect.all([ | |
| 592 | clearBufferedAssistantText(messageId), | |
| 593 | Cache.invalidate(messageStreamKindByMessageId, messageId), | |
| 594 | ]).pipe(Effect.asVoid); | |
| 595 | const appendBufferedProposedPlan = (planId: string, delta: string, createdAt: string) => | |
| 596 | Cache.getOption(bufferedProposedPlanById, planId).pipe( | |
| 597 | Effect.flatMap((existingEntry) => { | |
| 598 | const existing = Option.getOrUndefined(existingEntry); | |
| 599 | return Cache.set(bufferedProposedPlanById, planId, { | |
| 600 | text: `${existing?.text ?? ""}${delta}`, | |
| 601 | createdAt: existing?.createdAt && existing.createdAt.length > 0 ? existing.createdAt : createdAt, | |
| 602 | }); | |
| 603 | }), | |
| 604 | ); | |
| 605 | ||
| 606 | const takeBufferedProposedPlan = (planId: string) => | |
| 607 | Cache.getOption(bufferedProposedPlanById, planId).pipe( | |
| 608 | Effect.flatMap((existingEntry) => | |
| 609 | Cache.invalidate(bufferedProposedPlanById, planId).pipe( | |
| 610 | Effect.as(Option.getOrUndefined(existingEntry)), | |
| 611 | ), | |
| 612 | ), | |
| 613 | ); | |
| 614 | ||
| 615 | const clearBufferedProposedPlan = (planId: string) => | |
| 616 | Cache.invalidate(bufferedProposedPlanById, planId); | |
| 617 | ||
| 618 | const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId); | |
| 595 | 619 | |
| 596 | 620 | const finalizeAssistantMessage = (input: { |
| 597 | 621 | event: ProviderRuntimeEvent; |
| ... | ... | @@ -604,17 +628,10 @@ const make = Effect.gen(function* () { |
| 604 | 628 | fallbackText?: string; |
| 605 | 629 | }) => |
| 606 | 630 | Effect.gen(function* () { |
| 607 | const messageStreamKind = yield* getMessageStreamKind(input.messageId); | |
| 608 | 631 | const bufferedText = yield* takeBufferedAssistantText(input.messageId); |
| 609 | const bufferedFinalText = | |
| 610 | bufferedText.length > 0 | |
| 611 | ? messageStreamKind === "plan_text" | |
| 612 | ? (wrapProposedPlanMessage(bufferedText) ?? "") | |
| 613 | : bufferedText | |
| 614 | : ""; | |
| 615 | 632 | const text = |
| 616 | bufferedFinalText.length > 0 | |
| 617 | ? bufferedFinalText | |
| 633 | bufferedText.length > 0 | |
| 634 | ? bufferedText | |
| 618 | 635 | : (input.fallbackText?.trim().length ?? 0) > 0 |
| 619 | 636 | ? input.fallbackText! |
| 620 | 637 | : ""; |
| ... | ... | @@ -642,10 +659,84 @@ const make = Effect.gen(function* () { |
| 642 | 659 | yield* clearAssistantMessageState(input.messageId); |
| 643 | 660 | }); |
| 644 | 661 | |
| 662 | const upsertProposedPlan = (input: { | |
| 663 | event: ProviderRuntimeEvent; | |
| 664 | threadId: ThreadId; | |
| 665 | threadProposedPlans: ReadonlyArray<{ | |
| 666 | id: string; | |
| 667 | createdAt: string; | |
| 668 | }>; | |
| 669 | planId: string; | |
| 670 | turnId?: TurnId; | |
| 671 | planMarkdown: string | undefined; | |
| 672 | createdAt: string; | |
| 673 | updatedAt: string; | |
| 674 | }) => | |
| 675 | Effect.gen(function* () { | |
| 676 | const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown); | |
| 677 | if (!planMarkdown) { | |
| 678 | return; | |
| 679 | } | |
| 680 | ||
| 681 | const existingPlan = input.threadProposedPlans.find((entry) => entry.id === input.planId); | |
| 682 | yield* orchestrationEngine.dispatch({ | |
| 683 | type: "thread.proposed-plan.upsert", | |
| 684 | commandId: providerCommandId(input.event, "proposed-plan-upsert"), | |
| 685 | threadId: input.threadId, | |
| 686 | proposedPlan: { | |
| 687 | id: input.planId, | |
| 688 | turnId: input.turnId ?? null, | |
| 689 | planMarkdown, | |
| 690 | createdAt: existingPlan?.createdAt ?? input.createdAt, | |
| 691 | updatedAt: input.updatedAt, | |
| 692 | }, | |
| 693 | createdAt: input.updatedAt, | |
| 694 | }); | |
| 695 | }); | |
| 696 | ||
| 697 | const finalizeBufferedProposedPlan = (input: { | |
| 698 | event: ProviderRuntimeEvent; | |
| 699 | threadId: ThreadId; | |
| 700 | threadProposedPlans: ReadonlyArray<{ | |
| 701 | id: string; | |
| 702 | createdAt: string; | |
| 703 | }>; | |
| 704 | planId: string; | |
| 705 | turnId?: TurnId; | |
| 706 | fallbackMarkdown?: string; | |
| 707 | updatedAt: string; | |
| 708 | }) => | |
| 709 | Effect.gen(function* () { | |
| 710 | const bufferedPlan = yield* takeBufferedProposedPlan(input.planId); | |
| 711 | const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text); | |
| 712 | const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown); | |
| 713 | const planMarkdown = bufferedMarkdown ?? fallbackMarkdown; | |
| 714 | if (!planMarkdown) { | |
| 715 | return; | |
| 716 | } | |
| 717 | ||
| 718 | yield* upsertProposedPlan({ | |
| 719 | event: input.event, | |
| 720 | threadId: input.threadId, | |
| 721 | threadProposedPlans: input.threadProposedPlans, | |
| 722 | planId: input.planId, | |
| 723 | ...(input.turnId ? { turnId: input.turnId } : {}), | |
| 724 | planMarkdown, | |
| 725 | createdAt: | |
| 726 | bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0 | |
| 727 | ? bufferedPlan.createdAt | |
| 728 | : input.updatedAt, | |
| 729 | updatedAt: input.updatedAt, | |
| 730 | }); | |
| 731 | yield* clearBufferedProposedPlan(input.planId); | |
| 732 | }); | |
| 733 | ||
| 645 | 734 | const clearTurnStateForSession = (threadId: ThreadId) => |
| 646 | 735 | Effect.gen(function* () { |
| 647 | 736 | const prefix = `${threadId}:`; |
| 737 | const proposedPlanPrefix = `plan:${threadId}:`; | |
| 648 | 738 | const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); |
| 739 | const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); | |
| 649 | 740 | yield* Effect.forEach( |
| 650 | 741 | turnKeys, |
| 651 | 742 | (key) => |
| ... | ... | @@ -665,6 +756,14 @@ const make = Effect.gen(function* () { |
| 665 | 756 | }), |
| 666 | 757 | { concurrency: 1 }, |
| 667 | 758 | ).pipe(Effect.asVoid); |
| 759 | yield* Effect.forEach( | |
| 760 | proposedPlanKeys, | |
| 761 | (key) => | |
| 762 | key.startsWith(proposedPlanPrefix) | |
| 763 | ? Cache.invalidate(bufferedProposedPlanById, key) | |
| 764 | : Effect.void, | |
| 765 | { concurrency: 1 }, | |
| 766 | ).pipe(Effect.asVoid); | |
| 668 | 767 | }); |
| 669 | 768 | |
| 670 | 769 | const processRuntimeEvent = (event: ProviderRuntimeEvent) => |
| ... | ... | @@ -768,24 +867,20 @@ const make = Effect.gen(function* () { |
| 768 | 867 | } |
| 769 | 868 | |
| 770 | 869 | const assistantDelta = |
| 771 | event.type === "content.delta" && | |
| 772 | (event.payload.streamKind === "assistant_text" || event.payload.streamKind === "plan_text") | |
| 870 | event.type === "content.delta" && event.payload.streamKind === "assistant_text" | |
| 773 | 871 | ? event.payload.delta |
| 774 | 872 | : undefined; |
| 873 | const proposedPlanDelta = | |
| 874 | event.type === "turn.proposed.delta" ? event.payload.delta : undefined; | |
| 775 | 875 | |
| 776 | 876 | if (assistantDelta && assistantDelta.length > 0) { |
| 777 | 877 | const assistantMessageId = MessageId.makeUnsafe( |
| 778 | 878 | `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, |
| 779 | 879 | ); |
| 780 | 880 | const turnId = toTurnId(event.turnId); |
| 781 | const messageStreamKind = | |
| 782 | event.type === "content.delta" && event.payload.streamKind === "plan_text" | |
| 783 | ? "plan_text" | |
| 784 | : "assistant_text"; | |
| 785 | 881 | if (turnId) { |
| 786 | 882 | yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); |
| 787 | 883 | } |
| 788 | yield* rememberMessageStreamKind(assistantMessageId, messageStreamKind); | |
| 789 | 884 | |
| 790 | 885 | const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); |
| 791 | 886 | if (assistantDeliveryMode === "buffered") { |
| ... | ... | @@ -814,15 +909,24 @@ const make = Effect.gen(function* () { |
| 814 | 909 | } |
| 815 | 910 | } |
| 816 | 911 | |
| 912 | if (proposedPlanDelta && proposedPlanDelta.length > 0) { | |
| 913 | const planId = proposedPlanIdFromEvent(event, thread.id); | |
| 914 | yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now); | |
| 915 | } | |
| 916 | ||
| 817 | 917 | const assistantCompletion = |
| 818 | event.type === "item.completed" && | |
| 819 | (event.payload.itemType === "assistant_message" || event.payload.itemType === "plan") | |
| 918 | event.type === "item.completed" && event.payload.itemType === "assistant_message" | |
| 820 | 919 | ? { |
| 821 | 920 | messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`), |
| 822 | fallbackText: | |
| 823 | event.payload.itemType === "plan" | |
| 824 | ? wrapProposedPlanMessage(event.payload.detail) | |
| 825 | : event.payload.detail, | |
| 921 | fallbackText: event.payload.detail, | |
| 922 | } | |
| 923 | : undefined; | |
| 924 | const proposedPlanCompletion = | |
| 925 | event.type === "turn.proposed.completed" | |
| 926 | ? { | |
| 927 | planId: proposedPlanIdFromEvent(event, thread.id), | |
| 928 | turnId: toTurnId(event.turnId), | |
| 929 | planMarkdown: event.payload.planMarkdown, | |
| 826 | 930 | } |
| 827 | 931 | : undefined; |
| 828 | 932 | |
| ... | ... | @@ -851,6 +955,18 @@ const make = Effect.gen(function* () { |
| 851 | 955 | } |
| 852 | 956 | } |
| 853 | 957 | |
| 958 | if (proposedPlanCompletion) { | |
| 959 | yield* finalizeBufferedProposedPlan({ | |
| 960 | event, | |
| 961 | threadId: thread.id, | |
| 962 | threadProposedPlans: thread.proposedPlans, | |
| 963 | planId: proposedPlanCompletion.planId, | |
| 964 | ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}), | |
| 965 | fallbackMarkdown: proposedPlanCompletion.planMarkdown, | |
| 966 | updatedAt: now, | |
| 967 | }); | |
| 968 | } | |
| 969 | ||
| 854 | 970 | if (event.type === "turn.completed") { |
| 855 | 971 | const turnId = toTurnId(event.turnId); |
| 856 | 972 | if (turnId) { |
| ... | ... | @@ -870,6 +986,15 @@ const make = Effect.gen(function* () { |
| 870 | 986 | { concurrency: 1 }, |
| 871 | 987 | ).pipe(Effect.asVoid); |
| 872 | 988 | yield* clearAssistantMessageIdsForTurn(thread.id, turnId); |
| 989 | ||
| 990 | yield* finalizeBufferedProposedPlan({ | |
| 991 | event, | |
| 992 | threadId: thread.id, | |
| 993 | threadProposedPlans: thread.proposedPlans, | |
| 994 | planId: proposedPlanIdForTurn(thread.id, turnId), | |
| 995 | turnId, | |
| 996 | updatedAt: now, | |
| 997 | }); | |
| 873 | 998 | } |
| 874 | 999 | } |
| 875 | 1000 |
apps/server/src/orchestration/Schemas.ts+2| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, |
| 9 | 9 | ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, |
| 10 | 10 | ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, |
| 11 | ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, | |
| 11 | 12 | ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, |
| 12 | 13 | ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema, |
| 13 | 14 | ThreadRevertedPayload as ContractsThreadRevertedPayloadSchema, |
| ... | ... | @@ -31,6 +32,7 @@ export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSet |
| 31 | 32 | export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; |
| 32 | 33 | |
| 33 | 34 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; |
| 35 | export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; | |
| 34 | 36 | export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema; |
| 35 | 37 | export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema; |
| 36 | 38 | export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; |
apps/server/src/orchestration/commandInvariants.test.ts+2| ... | ... | @@ -61,6 +61,7 @@ const readModel: OrchestrationReadModel = { |
| 61 | 61 | messages: [], |
| 62 | 62 | session: null, |
| 63 | 63 | activities: [], |
| 64 | proposedPlans: [], | |
| 64 | 65 | checkpoints: [], |
| 65 | 66 | deletedAt: null, |
| 66 | 67 | }, |
| ... | ... | @@ -79,6 +80,7 @@ const readModel: OrchestrationReadModel = { |
| 79 | 80 | messages: [], |
| 80 | 81 | session: null, |
| 81 | 82 | activities: [], |
| 83 | proposedPlans: [], | |
| 82 | 84 | checkpoints: [], |
| 83 | 85 | deletedAt: null, |
| 84 | 86 | }, |
apps/server/src/orchestration/decider.ts+21| ... | ... | @@ -508,6 +508,27 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 508 | 508 | }; |
| 509 | 509 | } |
| 510 | 510 | |
| 511 | case "thread.proposed-plan.upsert": { | |
| 512 | yield* requireThread({ | |
| 513 | readModel, | |
| 514 | command, | |
| 515 | threadId: command.threadId, | |
| 516 | }); | |
| 517 | return { | |
| 518 | ...withEventBase({ | |
| 519 | aggregateKind: "thread", | |
| 520 | aggregateId: command.threadId, | |
| 521 | occurredAt: command.createdAt, | |
| 522 | commandId: command.commandId, | |
| 523 | }), | |
| 524 | type: "thread.proposed-plan-upserted", | |
| 525 | payload: { | |
| 526 | threadId: command.threadId, | |
| 527 | proposedPlan: command.proposedPlan, | |
| 528 | }, | |
| 529 | }; | |
| 530 | } | |
| 531 | ||
| 511 | 532 | case "thread.turn.diff.complete": { |
| 512 | 533 | yield* requireThread({ |
| 513 | 534 | readModel, |
apps/server/src/orchestration/projector.ts+47| ... | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | ThreadDeletedPayload, |
| 19 | 19 | ThreadInteractionModeSetPayload, |
| 20 | 20 | ThreadMetaUpdatedPayload, |
| 21 | ThreadProposedPlanUpsertedPayload, | |
| 21 | 22 | ThreadRuntimeModeSetPayload, |
| 22 | 23 | ThreadRevertedPayload, |
| 23 | 24 | ThreadSessionSetPayload, |
| ... | ... | @@ -126,6 +127,15 @@ function retainThreadActivitiesAfterRevert( |
| 126 | 127 | ); |
| 127 | 128 | } |
| 128 | 129 | |
| 130 | function retainThreadProposedPlansAfterRevert( | |
| 131 | proposedPlans: ReadonlyArray<OrchestrationThread["proposedPlans"][number]>, | |
| 132 | retainedTurnIds: ReadonlySet<string>, | |
| 133 | ): ReadonlyArray<OrchestrationThread["proposedPlans"][number]> { | |
| 134 | return proposedPlans.filter( | |
| 135 | (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), | |
| 136 | ); | |
| 137 | } | |
| 138 | ||
| 129 | 139 | function compareThreadActivities( |
| 130 | 140 | left: OrchestrationThread["activities"][number], |
| 131 | 141 | right: OrchestrationThread["activities"][number], |
| ... | ... | @@ -435,6 +445,38 @@ export function projectEvent( |
| 435 | 445 | }; |
| 436 | 446 | }); |
| 437 | 447 | |
| 448 | case "thread.proposed-plan-upserted": | |
| 449 | return Effect.gen(function* () { | |
| 450 | const payload = yield* decodeForEvent( | |
| 451 | ThreadProposedPlanUpsertedPayload, | |
| 452 | event.payload, | |
| 453 | event.type, | |
| 454 | "payload", | |
| 455 | ); | |
| 456 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 457 | if (!thread) { | |
| 458 | return nextBase; | |
| 459 | } | |
| 460 | ||
| 461 | const proposedPlans = [ | |
| 462 | ...thread.proposedPlans.filter((entry) => entry.id !== payload.proposedPlan.id), | |
| 463 | payload.proposedPlan, | |
| 464 | ] | |
| 465 | .toSorted( | |
| 466 | (left, right) => | |
| 467 | left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), | |
| 468 | ) | |
| 469 | .slice(-200); | |
| 470 | ||
| 471 | return { | |
| 472 | ...nextBase, | |
| 473 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 474 | proposedPlans, | |
| 475 | updatedAt: event.occurredAt, | |
| 476 | }), | |
| 477 | }; | |
| 478 | }); | |
| 479 | ||
| 438 | 480 | case "thread.turn-diff-completed": |
| 439 | 481 | return Effect.gen(function* () { |
| 440 | 482 | const payload = yield* decodeForEvent( |
| ... | ... | @@ -511,6 +553,10 @@ export function projectEvent( |
| 511 | 553 | retainedTurnIds, |
| 512 | 554 | payload.turnCount, |
| 513 | 555 | ).slice(-MAX_THREAD_MESSAGES); |
| 556 | const proposedPlans = retainThreadProposedPlansAfterRevert( | |
| 557 | thread.proposedPlans, | |
| 558 | retainedTurnIds, | |
| 559 | ).slice(-200); | |
| 514 | 560 | const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); |
| 515 | 561 | |
| 516 | 562 | const latestCheckpoint = checkpoints.at(-1) ?? null; |
| ... | ... | @@ -531,6 +577,7 @@ export function projectEvent( |
| 531 | 577 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 532 | 578 | checkpoints, |
| 533 | 579 | messages, |
| 580 | proposedPlans, | |
| 534 | 581 | activities, |
| 535 | 582 | latestTurn, |
| 536 | 583 | updatedAt: event.occurredAt, |
apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts created+104| ... | ... | @@ -0,0 +1,104 @@ |
| 1 | import { Effect, Layer } from "effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | import { | |
| 7 | DeleteProjectionThreadProposedPlansInput, | |
| 8 | ListProjectionThreadProposedPlansInput, | |
| 9 | ProjectionThreadProposedPlan, | |
| 10 | ProjectionThreadProposedPlanRepository, | |
| 11 | type ProjectionThreadProposedPlanRepositoryShape, | |
| 12 | } from "../Services/ProjectionThreadProposedPlans.ts"; | |
| 13 | ||
| 14 | const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { | |
| 15 | const sql = yield* SqlClient.SqlClient; | |
| 16 | ||
| 17 | const upsertProjectionThreadProposedPlanRow = SqlSchema.void({ | |
| 18 | Request: ProjectionThreadProposedPlan, | |
| 19 | execute: (row) => sql` | |
| 20 | INSERT INTO projection_thread_proposed_plans ( | |
| 21 | plan_id, | |
| 22 | thread_id, | |
| 23 | turn_id, | |
| 24 | plan_markdown, | |
| 25 | created_at, | |
| 26 | updated_at | |
| 27 | ) | |
| 28 | VALUES ( | |
| 29 | ${row.planId}, | |
| 30 | ${row.threadId}, | |
| 31 | ${row.turnId}, | |
| 32 | ${row.planMarkdown}, | |
| 33 | ${row.createdAt}, | |
| 34 | ${row.updatedAt} | |
| 35 | ) | |
| 36 | ON CONFLICT (plan_id) | |
| 37 | DO UPDATE SET | |
| 38 | thread_id = excluded.thread_id, | |
| 39 | turn_id = excluded.turn_id, | |
| 40 | plan_markdown = excluded.plan_markdown, | |
| 41 | created_at = excluded.created_at, | |
| 42 | updated_at = excluded.updated_at | |
| 43 | `, | |
| 44 | }); | |
| 45 | ||
| 46 | const listProjectionThreadProposedPlanRows = SqlSchema.findAll({ | |
| 47 | Request: ListProjectionThreadProposedPlansInput, | |
| 48 | Result: ProjectionThreadProposedPlan, | |
| 49 | execute: ({ threadId }) => sql` | |
| 50 | SELECT | |
| 51 | plan_id AS "planId", | |
| 52 | thread_id AS "threadId", | |
| 53 | turn_id AS "turnId", | |
| 54 | plan_markdown AS "planMarkdown", | |
| 55 | created_at AS "createdAt", | |
| 56 | updated_at AS "updatedAt" | |
| 57 | FROM projection_thread_proposed_plans | |
| 58 | WHERE thread_id = ${threadId} | |
| 59 | ORDER BY created_at ASC, plan_id ASC | |
| 60 | `, | |
| 61 | }); | |
| 62 | ||
| 63 | const deleteProjectionThreadProposedPlanRows = SqlSchema.void({ | |
| 64 | Request: DeleteProjectionThreadProposedPlansInput, | |
| 65 | execute: ({ threadId }) => sql` | |
| 66 | DELETE FROM projection_thread_proposed_plans | |
| 67 | WHERE thread_id = ${threadId} | |
| 68 | `, | |
| 69 | }); | |
| 70 | ||
| 71 | const upsert: ProjectionThreadProposedPlanRepositoryShape["upsert"] = (row) => | |
| 72 | upsertProjectionThreadProposedPlanRow(row).pipe( | |
| 73 | Effect.mapError( | |
| 74 | toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query"), | |
| 75 | ), | |
| 76 | ); | |
| 77 | ||
| 78 | const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) => | |
| 79 | listProjectionThreadProposedPlanRows(input).pipe( | |
| 80 | Effect.mapError( | |
| 81 | toPersistenceSqlError("ProjectionThreadProposedPlanRepository.listByThreadId:query"), | |
| 82 | ), | |
| 83 | ); | |
| 84 | ||
| 85 | const deleteByThreadId: ProjectionThreadProposedPlanRepositoryShape["deleteByThreadId"] = ( | |
| 86 | input, | |
| 87 | ) => | |
| 88 | deleteProjectionThreadProposedPlanRows(input).pipe( | |
| 89 | Effect.mapError( | |
| 90 | toPersistenceSqlError("ProjectionThreadProposedPlanRepository.deleteByThreadId:query"), | |
| 91 | ), | |
| 92 | ); | |
| 93 | ||
| 94 | return { | |
| 95 | upsert, | |
| 96 | listByThreadId, | |
| 97 | deleteByThreadId, | |
| 98 | } satisfies ProjectionThreadProposedPlanRepositoryShape; | |
| 99 | }); | |
| 100 | ||
| 101 | export const ProjectionThreadProposedPlanRepositoryLive = Layer.effect( | |
| 102 | ProjectionThreadProposedPlanRepository, | |
| 103 | makeProjectionThreadProposedPlanRepository, | |
| 104 | ); |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -24,6 +24,7 @@ import Migration0009 from "./Migrations/009_ProviderSessionRuntimeMode.ts"; |
| 24 | 24 | import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts"; |
| 25 | 25 | import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts"; |
| 26 | 26 | import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"; |
| 27 | import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; | |
| 27 | 28 | import { Effect } from "effect"; |
| 28 | 29 | |
| 29 | 30 | /** |
| ... | ... | @@ -49,6 +50,7 @@ const loader = Migrator.fromRecord({ |
| 49 | 50 | "10_ProjectionThreadsRuntimeMode": Migration0010, |
| 50 | 51 | "11_OrchestrationThreadCreatedRuntimeMode": Migration0011, |
| 51 | 52 | "12_ProjectionThreadsInteractionMode": Migration0012, |
| 53 | "13_ProjectionThreadProposedPlans": Migration0013, | |
| 52 | 54 | }); |
| 53 | 55 | |
| 54 | 56 | /** |
apps/server/src/persistence/Migrations/013_ProjectionThreadProposedPlans.ts created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | ||
| 7 | yield* sql` | |
| 8 | CREATE TABLE IF NOT EXISTS projection_thread_proposed_plans ( | |
| 9 | plan_id TEXT PRIMARY KEY, | |
| 10 | thread_id TEXT NOT NULL, | |
| 11 | turn_id TEXT, | |
| 12 | plan_markdown TEXT NOT NULL, | |
| 13 | created_at TEXT NOT NULL, | |
| 14 | updated_at TEXT NOT NULL | |
| 15 | ) | |
| 16 | `; | |
| 17 | ||
| 18 | yield* sql` | |
| 19 | CREATE INDEX IF NOT EXISTS idx_projection_thread_proposed_plans_thread_created | |
| 20 | ON projection_thread_proposed_plans(thread_id, created_at) | |
| 21 | `; | |
| 22 | }); |
apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts created+52| ... | ... | @@ -0,0 +1,52 @@ |
| 1 | import { | |
| 2 | IsoDateTime, | |
| 3 | OrchestrationProposedPlanId, | |
| 4 | ThreadId, | |
| 5 | TrimmedNonEmptyString, | |
| 6 | TurnId, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { Schema, ServiceMap } from "effect"; | |
| 9 | import type { Effect } from "effect"; | |
| 10 | ||
| 11 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 12 | ||
| 13 | export const ProjectionThreadProposedPlan = Schema.Struct({ | |
| 14 | planId: OrchestrationProposedPlanId, | |
| 15 | threadId: ThreadId, | |
| 16 | turnId: Schema.NullOr(TurnId), | |
| 17 | planMarkdown: TrimmedNonEmptyString, | |
| 18 | createdAt: IsoDateTime, | |
| 19 | updatedAt: IsoDateTime, | |
| 20 | }); | |
| 21 | export type ProjectionThreadProposedPlan = typeof ProjectionThreadProposedPlan.Type; | |
| 22 | ||
| 23 | export const ListProjectionThreadProposedPlansInput = Schema.Struct({ | |
| 24 | threadId: ThreadId, | |
| 25 | }); | |
| 26 | export type ListProjectionThreadProposedPlansInput = | |
| 27 | typeof ListProjectionThreadProposedPlansInput.Type; | |
| 28 | ||
| 29 | export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ | |
| 30 | threadId: ThreadId, | |
| 31 | }); | |
| 32 | export type DeleteProjectionThreadProposedPlansInput = | |
| 33 | typeof DeleteProjectionThreadProposedPlansInput.Type; | |
| 34 | ||
| 35 | export interface ProjectionThreadProposedPlanRepositoryShape { | |
| 36 | readonly upsert: ( | |
| 37 | proposedPlan: ProjectionThreadProposedPlan, | |
| 38 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 39 | readonly listByThreadId: ( | |
| 40 | input: ListProjectionThreadProposedPlansInput, | |
| 41 | ) => Effect.Effect<ReadonlyArray<ProjectionThreadProposedPlan>, ProjectionRepositoryError>; | |
| 42 | readonly deleteByThreadId: ( | |
| 43 | input: DeleteProjectionThreadProposedPlansInput, | |
| 44 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 45 | } | |
| 46 | ||
| 47 | export class ProjectionThreadProposedPlanRepository extends ServiceMap.Service< | |
| 48 | ProjectionThreadProposedPlanRepository, | |
| 49 | ProjectionThreadProposedPlanRepositoryShape | |
| 50 | >()( | |
| 51 | "t3/persistence/Services/ProjectionThreadProposedPlans/ProjectionThreadProposedPlanRepository", | |
| 52 | ) {} |
apps/server/src/provider/Layers/CodexAdapter.test.ts+46-7| ... | ... | @@ -270,7 +270,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 270 | 270 | }), |
| 271 | 271 | ); |
| 272 | 272 | |
| 273 | it.effect("maps completed plan items to canonical item.completed plan events", () => | |
| 273 | it.effect("maps completed plan items to canonical proposed-plan completion events", () => | |
| 274 | 274 | Effect.gen(function* () { |
| 275 | 275 | const adapter = yield* CodexAdapter; |
| 276 | 276 | const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); |
| ... | ... | @@ -300,13 +300,46 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 300 | 300 | if (firstEvent._tag !== "Some") { |
| 301 | 301 | return; |
| 302 | 302 | } |
| 303 | assert.equal(firstEvent.value.type, "item.completed"); | |
| 304 | if (firstEvent.value.type !== "item.completed") { | |
| 303 | assert.equal(firstEvent.value.type, "turn.proposed.completed"); | |
| 304 | if (firstEvent.value.type !== "turn.proposed.completed") { | |
| 305 | return; | |
| 306 | } | |
| 307 | assert.equal(firstEvent.value.turnId, "turn-1"); | |
| 308 | assert.equal(firstEvent.value.payload.planMarkdown, "## Final plan\n\n- one\n- two"); | |
| 309 | }), | |
| 310 | ); | |
| 311 | ||
| 312 | it.effect("maps plan deltas to canonical proposed-plan delta events", () => | |
| 313 | Effect.gen(function* () { | |
| 314 | const adapter = yield* CodexAdapter; | |
| 315 | const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); | |
| 316 | ||
| 317 | lifecycleManager.emit("event", { | |
| 318 | id: asEventId("evt-plan-delta"), | |
| 319 | kind: "notification", | |
| 320 | provider: "codex", | |
| 321 | createdAt: new Date().toISOString(), | |
| 322 | method: "item/plan/delta", | |
| 323 | threadId: asThreadId("thread-1"), | |
| 324 | turnId: asTurnId("turn-1"), | |
| 325 | itemId: asItemId("plan_1"), | |
| 326 | payload: { | |
| 327 | delta: "## Final plan", | |
| 328 | }, | |
| 329 | } satisfies ProviderEvent); | |
| 330 | ||
| 331 | const firstEvent = yield* Fiber.join(firstEventFiber); | |
| 332 | ||
| 333 | assert.equal(firstEvent._tag, "Some"); | |
| 334 | if (firstEvent._tag !== "Some") { | |
| 335 | return; | |
| 336 | } | |
| 337 | assert.equal(firstEvent.value.type, "turn.proposed.delta"); | |
| 338 | if (firstEvent.value.type !== "turn.proposed.delta") { | |
| 305 | 339 | return; |
| 306 | 340 | } |
| 307 | assert.equal(firstEvent.value.itemId, "plan_1"); | |
| 308 | assert.equal(firstEvent.value.payload.itemType, "plan"); | |
| 309 | assert.equal(firstEvent.value.payload.detail, "## Final plan\n\n- one\n- two"); | |
| 341 | assert.equal(firstEvent.value.turnId, "turn-1"); | |
| 342 | assert.equal(firstEvent.value.payload.delta, "## Final plan"); | |
| 310 | 343 | }), |
| 311 | 344 | ); |
| 312 | 345 | |
| ... | ... | @@ -487,7 +520,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 487 | 520 | it.effect("maps Codex task and reasoning event chunks into canonical runtime events", () => |
| 488 | 521 | Effect.gen(function* () { |
| 489 | 522 | const adapter = yield* CodexAdapter; |
| 490 | const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 4)).pipe( | |
| 523 | const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 5)).pipe( | |
| 491 | 524 | Effect.forkChild, |
| 492 | 525 | ); |
| 493 | 526 | |
| ... | ... | @@ -592,6 +625,12 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 592 | 625 | assert.equal(events[3].payload.taskId, "turn-structured-1"); |
| 593 | 626 | assert.equal(events[3].payload.summary, "<proposed_plan>\n# Ship it\n</proposed_plan>"); |
| 594 | 627 | } |
| 628 | ||
| 629 | assert.equal(events[4]?.type, "turn.proposed.completed"); | |
| 630 | if (events[4]?.type === "turn.proposed.completed") { | |
| 631 | assert.equal(events[4].turnId, "turn-structured-1"); | |
| 632 | assert.equal(events[4].payload.planMarkdown, "# Ship it"); | |
| 633 | } | |
| 595 | 634 | }), |
| 596 | 635 | ); |
| 597 | 636 | }); |
apps/server/src/provider/Layers/CodexAdapter.ts+75-6| ... | ... | @@ -374,8 +374,6 @@ function contentStreamKindFromMethod( |
| 374 | 374 | return "reasoning_text"; |
| 375 | 375 | case "item/reasoning/summaryTextDelta": |
| 376 | 376 | return "reasoning_summary_text"; |
| 377 | case "item/plan/delta": | |
| 378 | return "plan_text"; | |
| 379 | 377 | case "item/commandExecution/outputDelta": |
| 380 | 378 | return "command_output"; |
| 381 | 379 | case "item/fileChange/outputDelta": |
| ... | ... | @@ -385,6 +383,14 @@ function contentStreamKindFromMethod( |
| 385 | 383 | } |
| 386 | 384 | } |
| 387 | 385 | |
| 386 | const PROPOSED_PLAN_BLOCK_REGEX = /<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/i; | |
| 387 | ||
| 388 | function extractProposedPlanMarkdown(text: string | undefined): string | undefined { | |
| 389 | const match = text ? PROPOSED_PLAN_BLOCK_REGEX.exec(text) : null; | |
| 390 | const planMarkdown = match?.[1]?.trim(); | |
| 391 | return planMarkdown && planMarkdown.length > 0 ? planMarkdown : undefined; | |
| 392 | } | |
| 393 | ||
| 388 | 394 | function asRuntimeItemId(itemId: ProviderItemId): RuntimeItemId { |
| 389 | 395 | return RuntimeItemId.makeUnsafe(itemId); |
| 390 | 396 | } |
| ... | ... | @@ -487,7 +493,7 @@ function mapItemLifecycle( |
| 487 | 493 | return undefined; |
| 488 | 494 | } |
| 489 | 495 | |
| 490 | const detail = itemDetail(source, payload ?? source); | |
| 496 | const detail = itemDetail(source, payload ?? {}); | |
| 491 | 497 | const status = |
| 492 | 498 | lifecycle === "item.started" |
| 493 | 499 | ? "inProgress" |
| ... | ... | @@ -800,6 +806,28 @@ function mapToRuntimeEvents( |
| 800 | 806 | } |
| 801 | 807 | |
| 802 | 808 | if (event.method === "item/completed") { |
| 809 | const payload = asObject(event.payload); | |
| 810 | const item = asObject(payload?.item); | |
| 811 | const source = item ?? payload; | |
| 812 | if (!source) { | |
| 813 | return []; | |
| 814 | } | |
| 815 | const itemType = source ? toCanonicalItemType(source.type ?? source.kind) : "unknown"; | |
| 816 | if (itemType === "plan") { | |
| 817 | const detail = itemDetail(source, payload ?? {}); | |
| 818 | if (!detail) { | |
| 819 | return []; | |
| 820 | } | |
| 821 | return [ | |
| 822 | { | |
| 823 | ...runtimeEventBase(event, canonicalThreadId), | |
| 824 | type: "turn.proposed.completed", | |
| 825 | payload: { | |
| 826 | planMarkdown: detail, | |
| 827 | }, | |
| 828 | }, | |
| 829 | ]; | |
| 830 | } | |
| 803 | 831 | const completed = mapItemLifecycle(event, canonicalThreadId, "item.completed"); |
| 804 | 832 | return completed ? [completed] : []; |
| 805 | 833 | } |
| ... | ... | @@ -812,9 +840,28 @@ function mapToRuntimeEvents( |
| 812 | 840 | return updated ? [updated] : []; |
| 813 | 841 | } |
| 814 | 842 | |
| 843 | if (event.method === "item/plan/delta") { | |
| 844 | const delta = | |
| 845 | event.textDelta ?? | |
| 846 | asString(payload?.delta) ?? | |
| 847 | asString(payload?.text) ?? | |
| 848 | asString(asObject(payload?.content)?.text); | |
| 849 | if (!delta || delta.length === 0) { | |
| 850 | return []; | |
| 851 | } | |
| 852 | return [ | |
| 853 | { | |
| 854 | ...runtimeEventBase(event, canonicalThreadId), | |
| 855 | type: "turn.proposed.delta", | |
| 856 | payload: { | |
| 857 | delta, | |
| 858 | }, | |
| 859 | }, | |
| 860 | ]; | |
| 861 | } | |
| 862 | ||
| 815 | 863 | if ( |
| 816 | 864 | event.method === "item/agentMessage/delta" || |
| 817 | event.method === "item/plan/delta" || | |
| 818 | 865 | event.method === "item/commandExecution/outputDelta" || |
| 819 | 866 | event.method === "item/fileChange/outputDelta" || |
| 820 | 867 | event.method === "item/reasoning/summaryTextDelta" || |
| ... | ... | @@ -919,10 +966,22 @@ function mapToRuntimeEvents( |
| 919 | 966 | if (event.method === "codex/event/task_complete") { |
| 920 | 967 | const msg = codexEventMessage(payload); |
| 921 | 968 | const taskId = asString(payload?.id) ?? asString(msg?.turn_id); |
| 969 | const proposedPlanMarkdown = extractProposedPlanMarkdown(asString(msg?.last_agent_message)); | |
| 922 | 970 | if (!taskId) { |
| 923 | return []; | |
| 971 | if (!proposedPlanMarkdown) { | |
| 972 | return []; | |
| 973 | } | |
| 974 | return [ | |
| 975 | { | |
| 976 | ...codexEventBase(event, canonicalThreadId), | |
| 977 | type: "turn.proposed.completed", | |
| 978 | payload: { | |
| 979 | planMarkdown: proposedPlanMarkdown, | |
| 980 | }, | |
| 981 | }, | |
| 982 | ]; | |
| 924 | 983 | } |
| 925 | return [ | |
| 984 | const events: ProviderRuntimeEvent[] = [ | |
| 926 | 985 | { |
| 927 | 986 | ...codexEventBase(event, canonicalThreadId), |
| 928 | 987 | type: "task.completed", |
| ... | ... | @@ -935,6 +994,16 @@ function mapToRuntimeEvents( |
| 935 | 994 | }, |
| 936 | 995 | }, |
| 937 | 996 | ]; |
| 997 | if (proposedPlanMarkdown) { | |
| 998 | events.push({ | |
| 999 | ...codexEventBase(event, canonicalThreadId), | |
| 1000 | type: "turn.proposed.completed", | |
| 1001 | payload: { | |
| 1002 | planMarkdown: proposedPlanMarkdown, | |
| 1003 | }, | |
| 1004 | }); | |
| 1005 | } | |
| 1006 | return events; | |
| 938 | 1007 | } |
| 939 | 1008 | |
| 940 | 1009 | if (event.method === "codex/event/agent_reasoning") { |
apps/web/src/components/ChatView.tsx+105-76| ... | ... | @@ -72,6 +72,7 @@ import { |
| 72 | 72 | derivePhase, |
| 73 | 73 | deriveTimelineEntries, |
| 74 | 74 | deriveActivePlanState, |
| 75 | findLatestProposedPlan, | |
| 75 | 76 | type PendingApproval, |
| 76 | 77 | type PendingUserInput, |
| 77 | 78 | PROVIDER_OPTIONS, |
| ... | ... | @@ -93,8 +94,6 @@ import { |
| 93 | 94 | buildPlanImplementationThreadTitle, |
| 94 | 95 | buildPlanImplementationPrompt, |
| 95 | 96 | buildProposedPlanMarkdownFilename, |
| 96 | findLatestProposedPlanMessage, | |
| 97 | parseProposedPlanMessage, | |
| 98 | 97 | proposedPlanTitle, |
| 99 | 98 | } from "../proposedPlan"; |
| 100 | 99 | import { truncateTitle } from "../truncateTitle"; |
| ... | ... | @@ -334,6 +333,7 @@ function buildLocalDraftThread( |
| 334 | 333 | worktreePath: draftThread.worktreePath, |
| 335 | 334 | turnDiffSummaries: [], |
| 336 | 335 | activities: [], |
| 336 | proposedPlans: [], | |
| 337 | 337 | }; |
| 338 | 338 | } |
| 339 | 339 | |
| ... | ... | @@ -904,11 +904,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 904 | 904 | if (!latestTurnSettled) { |
| 905 | 905 | return null; |
| 906 | 906 | } |
| 907 | return findLatestProposedPlanMessage( | |
| 908 | activeThread?.messages ?? [], | |
| 909 | activeLatestTurn?.assistantMessageId ?? null, | |
| 907 | return findLatestProposedPlan( | |
| 908 | activeThread?.proposedPlans ?? [], | |
| 909 | activeLatestTurn?.turnId ?? null, | |
| 910 | 910 | ); |
| 911 | }, [activeLatestTurn?.assistantMessageId, activeThread?.messages, latestTurnSettled]); | |
| 911 | }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); | |
| 912 | 912 | const activePlan = useMemo( |
| 913 | 913 | () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), |
| 914 | 914 | [activeLatestTurn?.turnId, threadActivities], |
| ... | ... | @@ -1055,8 +1055,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1055 | 1055 | return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; |
| 1056 | 1056 | }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); |
| 1057 | 1057 | const timelineEntries = useMemo( |
| 1058 | () => deriveTimelineEntries(timelineMessages, workLogEntries), | |
| 1059 | [timelineMessages, workLogEntries], | |
| 1058 | () => deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), | |
| 1059 | [activeThread?.proposedPlans, timelineMessages, workLogEntries], | |
| 1060 | 1060 | ); |
| 1061 | 1061 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 1062 | 1062 | useTurnDiffSummaries(activeThread); |
| ... | ... | @@ -2279,8 +2279,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2279 | 2279 | if (showPlanFollowUpPrompt && activeProposedPlan) { |
| 2280 | 2280 | const followUpText = |
| 2281 | 2281 | trimmed.length > 0 |
| 2282 | ? trimmed | |
| 2283 | : buildPlanImplementationPrompt(activeProposedPlan.plan.planMarkdown); | |
| 2282 | ? trimmed | |
| 2283 | : buildPlanImplementationPrompt(activeProposedPlan.planMarkdown); | |
| 2284 | 2284 | const nextInteractionMode = trimmed.length > 0 ? "plan" : "default"; |
| 2285 | 2285 | promptRef.current = ""; |
| 2286 | 2286 | clearComposerDraftContent(activeThread.id); |
| ... | ... | @@ -2815,7 +2815,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2815 | 2815 | |
| 2816 | 2816 | const createdAt = new Date().toISOString(); |
| 2817 | 2817 | const nextThreadId = newThreadId(); |
| 2818 | const planMarkdown = activeProposedPlan.plan.planMarkdown; | |
| 2818 | const planMarkdown = activeProposedPlan.planMarkdown; | |
| 2819 | 2819 | const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); |
| 2820 | 2820 | const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); |
| 2821 | 2821 | const nextThreadModel: ModelSlug = |
| ... | ... | @@ -3334,7 +3334,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3334 | 3334 | > |
| 3335 | 3335 | <MessagesTimeline |
| 3336 | 3336 | key={activeThread.id} |
| 3337 | hasMessages={timelineMessages.length > 0} | |
| 3337 | hasMessages={timelineEntries.length > 0} | |
| 3338 | 3338 | isWorking={isWorking} |
| 3339 | 3339 | activeTurnInProgress={!latestTurnSettled} |
| 3340 | 3340 | activeTurnStartedAt={activeLatestTurn?.startedAt ?? null} |
| ... | ... | @@ -3387,8 +3387,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3387 | 3387 | ) : showPlanFollowUpPrompt && activeProposedPlan ? ( |
| 3388 | 3388 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> |
| 3389 | 3389 | <ComposerPlanFollowUpBanner |
| 3390 | key={activeProposedPlan.message.id} | |
| 3391 | planTitle={proposedPlanTitle(activeProposedPlan.plan.planMarkdown) ?? null} | |
| 3390 | key={activeProposedPlan.id} | |
| 3391 | planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null} | |
| 3392 | 3392 | /> |
| 3393 | 3393 | </div> |
| 3394 | 3394 | ) : null} |
| ... | ... | @@ -4205,7 +4205,6 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane |
| 4205 | 4205 | <ComposerPendingUserInputCard |
| 4206 | 4206 | key={activePrompt.requestId} |
| 4207 | 4207 | prompt={activePrompt} |
| 4208 | pendingPromptCount={pendingUserInputs.length} | |
| 4209 | 4208 | isResponding={respondingRequestIds.includes(activePrompt.requestId)} |
| 4210 | 4209 | answers={answers} |
| 4211 | 4210 | questionIndex={questionIndex} |
| ... | ... | @@ -4216,14 +4215,12 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane |
| 4216 | 4215 | |
| 4217 | 4216 | const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({ |
| 4218 | 4217 | prompt, |
| 4219 | pendingPromptCount, | |
| 4220 | 4218 | isResponding, |
| 4221 | 4219 | answers, |
| 4222 | 4220 | questionIndex, |
| 4223 | 4221 | onSelectOption, |
| 4224 | 4222 | }: { |
| 4225 | 4223 | prompt: PendingUserInput; |
| 4226 | pendingPromptCount: number; | |
| 4227 | 4224 | isResponding: boolean; |
| 4228 | 4225 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 4229 | 4226 | questionIndex: number; |
| ... | ... | @@ -4448,33 +4445,29 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 4448 | 4445 | return ( |
| 4449 | 4446 | <div className="space-y-0.5"> |
| 4450 | 4447 | {treeNodes.map((node) => renderTreeNode(node, 0))} |
| 4451 | const ProposedPlanMessage = memo(function ProposedPlanMessage({ | |
| 4452 | text, | |
| 4448 | </div> | |
| 4449 | ); | |
| 4450 | }); | |
| 4451 | ||
| 4452 | const ProposedPlanCard = memo(function ProposedPlanCard({ | |
| 4453 | planMarkdown, | |
| 4453 | 4454 | cwd, |
| 4454 | 4455 | workspaceRoot, |
| 4455 | isStreaming, | |
| 4456 | 4456 | }: { |
| 4457 | text: string; | |
| 4457 | planMarkdown: string; | |
| 4458 | 4458 | cwd: string | undefined; |
| 4459 | 4459 | workspaceRoot: string | undefined; |
| 4460 | isStreaming: boolean; | |
| 4461 | 4460 | }) { |
| 4462 | const proposedPlan = parseProposedPlanMessage(text); | |
| 4463 | 4461 | const [expanded, setExpanded] = useState(false); |
| 4464 | 4462 | const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); |
| 4465 | 4463 | const [savePath, setSavePath] = useState(""); |
| 4466 | 4464 | const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); |
| 4467 | 4465 | const savePathInputId = useId(); |
| 4468 | ||
| 4469 | if (!proposedPlan) { | |
| 4470 | return <ChatMarkdown text={text} cwd={cwd} isStreaming={isStreaming} />; | |
| 4471 | } | |
| 4472 | ||
| 4473 | const title = proposedPlanTitle(proposedPlan.planMarkdown) ?? "Proposed plan"; | |
| 4474 | const lineCount = proposedPlan.planMarkdown.split("\n").length; | |
| 4475 | const canCollapse = proposedPlan.planMarkdown.length > 900 || lineCount > 20; | |
| 4476 | const downloadFilename = buildProposedPlanMarkdownFilename(proposedPlan.planMarkdown); | |
| 4477 | const saveContents = normalizePlanMarkdownForExport(proposedPlan.planMarkdown); | |
| 4466 | const title = proposedPlanTitle(planMarkdown) ?? "Proposed plan"; | |
| 4467 | const lineCount = planMarkdown.split("\n").length; | |
| 4468 | const canCollapse = planMarkdown.length > 900 || lineCount > 20; | |
| 4469 | const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown); | |
| 4470 | const saveContents = normalizePlanMarkdownForExport(planMarkdown); | |
| 4478 | 4471 | |
| 4479 | 4472 | const handleDownload = () => { |
| 4480 | 4473 | downloadTextFile(downloadFilename, saveContents); |
| ... | ... | @@ -4540,46 +4533,40 @@ const ProposedPlanMessage = memo(function ProposedPlanMessage({ |
| 4540 | 4533 | }; |
| 4541 | 4534 | |
| 4542 | 4535 | return ( |
| 4543 | <div className="space-y-3"> | |
| 4544 | {proposedPlan.beforeText ? ( | |
| 4545 | <ChatMarkdown text={proposedPlan.beforeText} cwd={cwd} isStreaming={isStreaming} /> | |
| 4546 | ) : null} | |
| 4547 | ||
| 4548 | <div className="rounded-[24px] border border-border/80 bg-card/70 p-4 sm:p-5"> | |
| 4549 | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 4550 | <div className="flex min-w-0 items-center gap-2"> | |
| 4551 | <Badge variant="secondary">Plan</Badge> | |
| 4552 | <p className="truncate text-sm font-medium text-foreground">{title}</p> | |
| 4553 | </div> | |
| 4554 | <Menu> | |
| 4555 | <MenuTrigger | |
| 4556 | render={<Button aria-label="Plan actions" size="icon-xs" variant="outline" />} | |
| 4557 | > | |
| 4558 | <EllipsisIcon aria-hidden="true" className="size-4" /> | |
| 4559 | </MenuTrigger> | |
| 4560 | <MenuPopup align="end"> | |
| 4561 | <MenuItem onClick={handleDownload}>Download as markdown</MenuItem> | |
| 4562 | <MenuItem onClick={openSaveDialog} disabled={!workspaceRoot || isSavingToWorkspace}> | |
| 4563 | Save to workspace | |
| 4564 | </MenuItem> | |
| 4565 | </MenuPopup> | |
| 4566 | </Menu> | |
| 4536 | <div className="rounded-[24px] border border-border/80 bg-card/70 p-4 sm:p-5"> | |
| 4537 | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 4538 | <div className="flex min-w-0 items-center gap-2"> | |
| 4539 | <Badge variant="secondary">Plan</Badge> | |
| 4540 | <p className="truncate text-sm font-medium text-foreground">{title}</p> | |
| 4567 | 4541 | </div> |
| 4568 | <div className="mt-4"> | |
| 4569 | <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}> | |
| 4570 | <ChatMarkdown text={proposedPlan.planMarkdown} cwd={cwd} isStreaming={false} /> | |
| 4571 | {canCollapse && !expanded ? ( | |
| 4572 | <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" /> | |
| 4573 | ) : null} | |
| 4574 | </div> | |
| 4575 | {canCollapse ? ( | |
| 4576 | <div className="mt-4 flex justify-center"> | |
| 4577 | <Button size="sm" variant="outline" onClick={() => setExpanded((value) => !value)}> | |
| 4578 | {expanded ? "Collapse plan" : "Expand plan"} | |
| 4579 | </Button> | |
| 4580 | </div> | |
| 4542 | <Menu> | |
| 4543 | <MenuTrigger | |
| 4544 | render={<Button aria-label="Plan actions" size="icon-xs" variant="outline" />} | |
| 4545 | > | |
| 4546 | <EllipsisIcon aria-hidden="true" className="size-4" /> | |
| 4547 | </MenuTrigger> | |
| 4548 | <MenuPopup align="end"> | |
| 4549 | <MenuItem onClick={handleDownload}>Download as markdown</MenuItem> | |
| 4550 | <MenuItem onClick={openSaveDialog} disabled={!workspaceRoot || isSavingToWorkspace}> | |
| 4551 | Save to workspace | |
| 4552 | </MenuItem> | |
| 4553 | </MenuPopup> | |
| 4554 | </Menu> | |
| 4555 | </div> | |
| 4556 | <div className="mt-4"> | |
| 4557 | <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}> | |
| 4558 | <ChatMarkdown text={planMarkdown} cwd={cwd} isStreaming={false} /> | |
| 4559 | {canCollapse && !expanded ? ( | |
| 4560 | <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" /> | |
| 4581 | 4561 | ) : null} |
| 4582 | 4562 | </div> |
| 4563 | {canCollapse ? ( | |
| 4564 | <div className="mt-4 flex justify-center"> | |
| 4565 | <Button size="sm" variant="outline" onClick={() => setExpanded((value) => !value)}> | |
| 4566 | {expanded ? "Collapse plan" : "Expand plan"} | |
| 4567 | </Button> | |
| 4568 | </div> | |
| 4569 | ) : null} | |
| 4583 | 4570 | </div> |
| 4584 | 4571 | |
| 4585 | 4572 | <Dialog |
| ... | ... | @@ -4629,10 +4616,6 @@ const ProposedPlanMessage = memo(function ProposedPlanMessage({ |
| 4629 | 4616 | </DialogFooter> |
| 4630 | 4617 | </DialogPopup> |
| 4631 | 4618 | </Dialog> |
| 4632 | ||
| 4633 | {proposedPlan.afterText ? ( | |
| 4634 | <ChatMarkdown text={proposedPlan.afterText} cwd={cwd} isStreaming={isStreaming} /> | |
| 4635 | ) : null} | |
| 4636 | 4619 | </div> |
| 4637 | 4620 | ); |
| 4638 | 4621 | }); |
| ... | ... | @@ -4662,6 +4645,7 @@ interface MessagesTimelineProps { |
| 4662 | 4645 | |
| 4663 | 4646 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; |
| 4664 | 4647 | type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; |
| 4648 | type TimelineProposedPlan = Extract<TimelineEntry, { kind: "proposed-plan" }>["proposedPlan"]; | |
| 4665 | 4649 | type TimelineWorkEntry = Extract<TimelineEntry, { kind: "work" }>["entry"]; |
| 4666 | 4650 | type TimelineRow = |
| 4667 | 4651 | | { |
| ... | ... | @@ -4677,8 +4661,33 @@ type TimelineRow = |
| 4677 | 4661 | message: TimelineMessage; |
| 4678 | 4662 | showCompletionDivider: boolean; |
| 4679 | 4663 | } |
| 4664 | | { | |
| 4665 | kind: "proposed-plan"; | |
| 4666 | id: string; | |
| 4667 | createdAt: string; | |
| 4668 | proposedPlan: TimelineProposedPlan; | |
| 4669 | } | |
| 4680 | 4670 | | { kind: "working"; id: string; createdAt: string | null }; |
| 4681 | 4671 | |
| 4672 | function estimateTimelineMessageHeight(message: TimelineMessage): number { | |
| 4673 | const textLength = message.text.length; | |
| 4674 | if (message.role === "assistant") { | |
| 4675 | const estimatedLines = Math.max(1, Math.ceil(textLength / 72)); | |
| 4676 | return 78 + Math.min(estimatedLines * 22, 820); | |
| 4677 | } | |
| 4678 | ||
| 4679 | const estimatedLines = Math.max(1, Math.ceil(textLength / 56)); | |
| 4680 | const attachmentCount = message.attachments?.length ?? 0; | |
| 4681 | const attachmentRows = Math.ceil(attachmentCount / 2); | |
| 4682 | const attachmentHeight = attachmentRows * 124; | |
| 4683 | return 96 + Math.min(estimatedLines * 22, 620) + attachmentHeight; | |
| 4684 | } | |
| 4685 | ||
| 4686 | function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan): number { | |
| 4687 | const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); | |
| 4688 | return 120 + Math.min(estimatedLines * 22, 880); | |
| 4689 | } | |
| 4690 | ||
| 4682 | 4691 | const MessagesTimeline = memo(function MessagesTimeline({ |
| 4683 | 4692 | hasMessages, |
| 4684 | 4693 | isWorking, |
| ... | ... | @@ -4757,6 +4766,16 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 4757 | 4766 | continue; |
| 4758 | 4767 | } |
| 4759 | 4768 | |
| 4769 | if (timelineEntry.kind === "proposed-plan") { | |
| 4770 | nextRows.push({ | |
| 4771 | kind: "proposed-plan", | |
| 4772 | id: timelineEntry.id, | |
| 4773 | createdAt: timelineEntry.createdAt, | |
| 4774 | proposedPlan: timelineEntry.proposedPlan, | |
| 4775 | }); | |
| 4776 | continue; | |
| 4777 | } | |
| 4778 | ||
| 4760 | 4779 | nextRows.push({ |
| 4761 | 4780 | kind: "message", |
| 4762 | 4781 | id: timelineEntry.id, |
| ... | ... | @@ -4831,6 +4850,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 4831 | 4850 | const row = rows[index]; |
| 4832 | 4851 | if (!row) return 96; |
| 4833 | 4852 | if (row.kind === "work") return 112; |
| 4853 | if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan); | |
| 4834 | 4854 | if (row.kind === "working") return 40; |
| 4835 | 4855 | return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); |
| 4836 | 4856 | }, |
| ... | ... | @@ -5044,10 +5064,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 5044 | 5064 | </div> |
| 5045 | 5065 | )} |
| 5046 | 5066 | <div className="min-w-0 px-1 py-0.5"> |
| 5047 | <ProposedPlanMessage | |
| 5067 | <ChatMarkdown | |
| 5048 | 5068 | text={messageText} |
| 5049 | 5069 | cwd={markdownCwd} |
| 5050 | workspaceRoot={workspaceRoot} | |
| 5051 | 5070 | isStreaming={Boolean(row.message.streaming)} |
| 5052 | 5071 | /> |
| 5053 | 5072 | {(() => { |
| ... | ... | @@ -5119,6 +5138,16 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 5119 | 5138 | ); |
| 5120 | 5139 | })()} |
| 5121 | 5140 | |
| 5141 | {row.kind === "proposed-plan" && ( | |
| 5142 | <div className="min-w-0 px-1 py-0.5"> | |
| 5143 | <ProposedPlanCard | |
| 5144 | planMarkdown={row.proposedPlan.planMarkdown} | |
| 5145 | cwd={markdownCwd} | |
| 5146 | workspaceRoot={workspaceRoot} | |
| 5147 | /> | |
| 5148 | </div> | |
| 5149 | )} | |
| 5150 | ||
| 5122 | 5151 | {row.kind === "working" && ( |
| 5123 | 5152 | <div className="flex items-center gap-2 py-0.5 pl-1.5"> |
| 5124 | 5153 | <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> |
apps/web/src/proposedPlan.test.ts-73| ... | ... | @@ -1,85 +1,12 @@ |
| 1 | import { MessageId } from "@t3tools/contracts"; | |
| 2 | 1 | import { describe, expect, it } from "vitest"; |
| 3 | 2 | |
| 4 | 3 | import { |
| 5 | 4 | buildPlanImplementationThreadTitle, |
| 6 | 5 | buildPlanImplementationPrompt, |
| 7 | 6 | buildProposedPlanMarkdownFilename, |
| 8 | findLatestProposedPlanMessage, | |
| 9 | parseProposedPlanMessage, | |
| 10 | 7 | proposedPlanTitle, |
| 11 | 8 | } from "./proposedPlan"; |
| 12 | 9 | |
| 13 | describe("parseProposedPlanMessage", () => { | |
| 14 | it("extracts the proposed plan block and surrounding assistant text", () => { | |
| 15 | expect( | |
| 16 | parseProposedPlanMessage( | |
| 17 | "First I checked the repo.\n\n<proposed_plan>\n# Ship plan\n\n- step 1\n</proposed_plan>\n\nImplement this plan?", | |
| 18 | ), | |
| 19 | ).toEqual({ | |
| 20 | beforeText: "First I checked the repo.", | |
| 21 | planMarkdown: "# Ship plan\n\n- step 1", | |
| 22 | afterText: "Implement this plan?", | |
| 23 | }); | |
| 24 | }); | |
| 25 | ||
| 26 | it("returns null when the assistant message has no plan block", () => { | |
| 27 | expect(parseProposedPlanMessage("Plain assistant text")).toBeNull(); | |
| 28 | }); | |
| 29 | }); | |
| 30 | ||
| 31 | describe("findLatestProposedPlanMessage", () => { | |
| 32 | it("prefers the latest turn assistant message id when it contains a proposed plan", () => { | |
| 33 | const match = findLatestProposedPlanMessage( | |
| 34 | [ | |
| 35 | { | |
| 36 | id: MessageId.makeUnsafe("assistant:older-plan"), | |
| 37 | role: "assistant", | |
| 38 | text: "<proposed_plan>\n# Older plan\n</proposed_plan>", | |
| 39 | createdAt: "2026-03-05T00:00:00.000Z", | |
| 40 | streaming: false, | |
| 41 | }, | |
| 42 | { | |
| 43 | id: MessageId.makeUnsafe("assistant:latest-plan"), | |
| 44 | role: "assistant", | |
| 45 | text: "<proposed_plan>\n# Latest plan\n</proposed_plan>", | |
| 46 | createdAt: "2026-03-05T00:01:00.000Z", | |
| 47 | streaming: false, | |
| 48 | }, | |
| 49 | ], | |
| 50 | "assistant:latest-plan", | |
| 51 | ); | |
| 52 | ||
| 53 | expect(match?.message.id).toBe("assistant:latest-plan"); | |
| 54 | expect(match?.plan.planMarkdown).toBe("# Latest plan"); | |
| 55 | }); | |
| 56 | ||
| 57 | it("falls back to the latest assistant proposed plan when no latest-turn message id is available", () => { | |
| 58 | const match = findLatestProposedPlanMessage( | |
| 59 | [ | |
| 60 | { | |
| 61 | id: MessageId.makeUnsafe("assistant:plain"), | |
| 62 | role: "assistant", | |
| 63 | text: "No plan here", | |
| 64 | createdAt: "2026-03-05T00:00:00.000Z", | |
| 65 | streaming: false, | |
| 66 | }, | |
| 67 | { | |
| 68 | id: MessageId.makeUnsafe("assistant:plan"), | |
| 69 | role: "assistant", | |
| 70 | text: "<proposed_plan>\n# Fallback plan\n</proposed_plan>", | |
| 71 | createdAt: "2026-03-05T00:01:00.000Z", | |
| 72 | streaming: false, | |
| 73 | }, | |
| 74 | ], | |
| 75 | null, | |
| 76 | ); | |
| 77 | ||
| 78 | expect(match?.message.id).toBe("assistant:plan"); | |
| 79 | expect(match?.plan.planMarkdown).toBe("# Fallback plan"); | |
| 80 | }); | |
| 81 | }); | |
| 82 | ||
| 83 | 10 | describe("proposedPlanTitle", () => { |
| 84 | 11 | it("reads the first markdown heading as the plan title", () => { |
| 85 | 12 | expect(proposedPlanTitle("# Integrate RPC\n\nBody")).toBe("Integrate RPC"); |
apps/web/src/proposedPlan.ts-76| ... | ... | @@ -1,42 +1,3 @@ |
| 1 | import type { MessageId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import type { ChatMessage } from "./types"; | |
| 4 | ||
| 5 | export interface ProposedPlanBlock { | |
| 6 | beforeText: string; | |
| 7 | planMarkdown: string; | |
| 8 | afterText: string; | |
| 9 | } | |
| 10 | ||
| 11 | export interface ProposedPlanMessageMatch { | |
| 12 | message: ChatMessage; | |
| 13 | plan: ProposedPlanBlock; | |
| 14 | } | |
| 15 | ||
| 16 | const PROPOSED_PLAN_REGEX = /<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/; | |
| 17 | ||
| 18 | export function parseProposedPlanMessage(text: string): ProposedPlanBlock | null { | |
| 19 | const match = PROPOSED_PLAN_REGEX.exec(text); | |
| 20 | if (!match) { | |
| 21 | return null; | |
| 22 | } | |
| 23 | ||
| 24 | const fullMatch = match[0]; | |
| 25 | const content = match[1]; | |
| 26 | const start = match.index; | |
| 27 | const end = start + fullMatch.length; | |
| 28 | const planMarkdown = content?.trim(); | |
| 29 | if (!planMarkdown) { | |
| 30 | return null; | |
| 31 | } | |
| 32 | ||
| 33 | return { | |
| 34 | beforeText: text.slice(0, start).trim(), | |
| 35 | planMarkdown, | |
| 36 | afterText: text.slice(end).trim(), | |
| 37 | }; | |
| 38 | } | |
| 39 | ||
| 40 | 1 | export function proposedPlanTitle(planMarkdown: string): string | null { |
| 41 | 2 | const heading = planMarkdown.match(/^\s{0,3}#{1,6}\s+(.+)$/m)?.[1]?.trim(); |
| 42 | 3 | return heading && heading.length > 0 ? heading : null; |
| ... | ... | @@ -51,43 +12,6 @@ function sanitizePlanFileSegment(input: string): string { |
| 51 | 12 | return sanitized.length > 0 ? sanitized : "plan"; |
| 52 | 13 | } |
| 53 | 14 | |
| 54 | export function findLatestProposedPlanMessage( | |
| 55 | messages: readonly ChatMessage[], | |
| 56 | assistantMessageId: MessageId | string | null | undefined, | |
| 57 | ): ProposedPlanMessageMatch | null { | |
| 58 | if (assistantMessageId) { | |
| 59 | const matchingMessage = messages.find( | |
| 60 | (message) => message.id === assistantMessageId && message.role === "assistant", | |
| 61 | ); | |
| 62 | if (matchingMessage) { | |
| 63 | const parsedPlan = parseProposedPlanMessage(matchingMessage.text); | |
| 64 | if (parsedPlan) { | |
| 65 | return { | |
| 66 | message: matchingMessage, | |
| 67 | plan: parsedPlan, | |
| 68 | }; | |
| 69 | } | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | for (let index = messages.length - 1; index >= 0; index -= 1) { | |
| 74 | const message = messages[index]; | |
| 75 | if (!message || message.role !== "assistant") { | |
| 76 | continue; | |
| 77 | } | |
| 78 | const parsedPlan = parseProposedPlanMessage(message.text); | |
| 79 | if (!parsedPlan) { | |
| 80 | continue; | |
| 81 | } | |
| 82 | return { | |
| 83 | message, | |
| 84 | plan: parsedPlan, | |
| 85 | }; | |
| 86 | } | |
| 87 | ||
| 88 | return null; | |
| 89 | } | |
| 90 | ||
| 91 | 15 | export function buildPlanImplementationPrompt(planMarkdown: string): string { |
| 92 | 16 | return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`; |
| 93 | 17 | } |
apps/web/src/session-logic.test.ts+107-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; | |
| 1 | import { EventId, MessageId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { |
| ... | ... | @@ -6,7 +6,9 @@ import { |
| 6 | 6 | PROVIDER_OPTIONS, |
| 7 | 7 | derivePendingApprovals, |
| 8 | 8 | derivePendingUserInputs, |
| 9 | deriveTimelineEntries, | |
| 9 | 10 | deriveWorkLogEntries, |
| 11 | findLatestProposedPlan, | |
| 10 | 12 | hasToolActivityForTurn, |
| 11 | 13 | isLatestTurnSettled, |
| 12 | 14 | } from "./session-logic"; |
| ... | ... | @@ -257,6 +259,69 @@ describe("deriveActivePlanState", () => { |
| 257 | 259 | }); |
| 258 | 260 | }); |
| 259 | 261 | |
| 262 | describe("findLatestProposedPlan", () => { | |
| 263 | it("prefers the latest proposed plan for the active turn", () => { | |
| 264 | expect( | |
| 265 | findLatestProposedPlan( | |
| 266 | [ | |
| 267 | { | |
| 268 | id: "plan:thread-1:turn:turn-1", | |
| 269 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 270 | planMarkdown: "# Older", | |
| 271 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 272 | updatedAt: "2026-02-23T00:00:01.000Z", | |
| 273 | }, | |
| 274 | { | |
| 275 | id: "plan:thread-1:turn:turn-1", | |
| 276 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 277 | planMarkdown: "# Latest", | |
| 278 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 279 | updatedAt: "2026-02-23T00:00:02.000Z", | |
| 280 | }, | |
| 281 | { | |
| 282 | id: "plan:thread-1:turn:turn-2", | |
| 283 | turnId: TurnId.makeUnsafe("turn-2"), | |
| 284 | planMarkdown: "# Different turn", | |
| 285 | createdAt: "2026-02-23T00:00:03.000Z", | |
| 286 | updatedAt: "2026-02-23T00:00:03.000Z", | |
| 287 | }, | |
| 288 | ], | |
| 289 | TurnId.makeUnsafe("turn-1"), | |
| 290 | ), | |
| 291 | ).toEqual({ | |
| 292 | id: "plan:thread-1:turn:turn-1", | |
| 293 | turnId: "turn-1", | |
| 294 | planMarkdown: "# Latest", | |
| 295 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 296 | updatedAt: "2026-02-23T00:00:02.000Z", | |
| 297 | }); | |
| 298 | }); | |
| 299 | ||
| 300 | it("falls back to the most recently updated proposed plan", () => { | |
| 301 | const latestPlan = findLatestProposedPlan( | |
| 302 | [ | |
| 303 | { | |
| 304 | id: "plan:thread-1:turn:turn-1", | |
| 305 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 306 | planMarkdown: "# First", | |
| 307 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 308 | updatedAt: "2026-02-23T00:00:01.000Z", | |
| 309 | }, | |
| 310 | { | |
| 311 | id: "plan:thread-1:turn:turn-2", | |
| 312 | turnId: TurnId.makeUnsafe("turn-2"), | |
| 313 | planMarkdown: "# Latest", | |
| 314 | createdAt: "2026-02-23T00:00:02.000Z", | |
| 315 | updatedAt: "2026-02-23T00:00:03.000Z", | |
| 316 | }, | |
| 317 | ], | |
| 318 | null, | |
| 319 | ); | |
| 320 | ||
| 321 | expect(latestPlan?.planMarkdown).toBe("# Latest"); | |
| 322 | }); | |
| 323 | }); | |
| 324 | ||
| 260 | 325 | describe("deriveWorkLogEntries", () => { |
| 261 | 326 | it("omits tool started entries and keeps completed entries", () => { |
| 262 | 327 | const activities: OrchestrationThreadActivity[] = [ |
| ... | ... | @@ -338,6 +403,47 @@ describe("deriveWorkLogEntries", () => { |
| 338 | 403 | }); |
| 339 | 404 | }); |
| 340 | 405 | |
| 406 | describe("deriveTimelineEntries", () => { | |
| 407 | it("includes proposed plans alongside messages and work entries in chronological order", () => { | |
| 408 | const entries = deriveTimelineEntries( | |
| 409 | [ | |
| 410 | { | |
| 411 | id: MessageId.makeUnsafe("message-1"), | |
| 412 | role: "assistant", | |
| 413 | text: "hello", | |
| 414 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 415 | streaming: false, | |
| 416 | }, | |
| 417 | ], | |
| 418 | [ | |
| 419 | { | |
| 420 | id: "plan:thread-1:turn:turn-1", | |
| 421 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 422 | planMarkdown: "# Ship it", | |
| 423 | createdAt: "2026-02-23T00:00:02.000Z", | |
| 424 | updatedAt: "2026-02-23T00:00:02.000Z", | |
| 425 | }, | |
| 426 | ], | |
| 427 | [ | |
| 428 | { | |
| 429 | id: "work-1", | |
| 430 | createdAt: "2026-02-23T00:00:03.000Z", | |
| 431 | label: "Ran tests", | |
| 432 | tone: "tool", | |
| 433 | }, | |
| 434 | ], | |
| 435 | ); | |
| 436 | ||
| 437 | expect(entries.map((entry) => entry.kind)).toEqual(["message", "proposed-plan", "work"]); | |
| 438 | expect(entries[1]).toMatchObject({ | |
| 439 | kind: "proposed-plan", | |
| 440 | proposedPlan: { | |
| 441 | planMarkdown: "# Ship it", | |
| 442 | }, | |
| 443 | }); | |
| 444 | }); | |
| 445 | }); | |
| 446 | ||
| 341 | 447 | describe("hasToolActivityForTurn", () => { |
| 342 | 448 | it("returns false when turn id is missing", () => { |
| 343 | 449 | const activities: OrchestrationThreadActivity[] = [ |
apps/web/src/session-logic.ts+68-2| ... | ... | @@ -2,12 +2,13 @@ import { |
| 2 | 2 | ApprovalRequestId, |
| 3 | 3 | type OrchestrationLatestTurn, |
| 4 | 4 | type OrchestrationThreadActivity, |
| 5 | type OrchestrationProposedPlanId, | |
| 5 | 6 | type ProviderKind, |
| 6 | 7 | type UserInputQuestion, |
| 7 | 8 | type TurnId, |
| 8 | 9 | } from "@t3tools/contracts"; |
| 9 | 10 | |
| 10 | import type { ChatMessage, SessionPhase, ThreadSession, TurnDiffSummary } from "./types"; | |
| 11 | import type { ChatMessage, ProposedPlan, SessionPhase, ThreadSession, TurnDiffSummary } from "./types"; | |
| 11 | 12 | |
| 12 | 13 | export const PROVIDER_OPTIONS: Array<{ |
| 13 | 14 | value: ProviderKind; |
| ... | ... | @@ -50,6 +51,14 @@ export interface ActivePlanState { |
| 50 | 51 | }>; |
| 51 | 52 | } |
| 52 | 53 | |
| 54 | export interface LatestProposedPlanState { | |
| 55 | id: OrchestrationProposedPlanId; | |
| 56 | createdAt: string; | |
| 57 | updatedAt: string; | |
| 58 | turnId: TurnId | null; | |
| 59 | planMarkdown: string; | |
| 60 | } | |
| 61 | ||
| 53 | 62 | export type TimelineEntry = |
| 54 | 63 | | { |
| 55 | 64 | id: string; |
| ... | ... | @@ -57,6 +66,12 @@ export type TimelineEntry = |
| 57 | 66 | createdAt: string; |
| 58 | 67 | message: ChatMessage; |
| 59 | 68 | } |
| 69 | | { | |
| 70 | id: string; | |
| 71 | kind: "proposed-plan"; | |
| 72 | createdAt: string; | |
| 73 | proposedPlan: ProposedPlan; | |
| 74 | } | |
| 60 | 75 | | { |
| 61 | 76 | id: string; |
| 62 | 77 | kind: "work"; |
| ... | ... | @@ -327,6 +342,48 @@ export function deriveActivePlanState( |
| 327 | 342 | }; |
| 328 | 343 | } |
| 329 | 344 | |
| 345 | export function findLatestProposedPlan( | |
| 346 | proposedPlans: ReadonlyArray<ProposedPlan>, | |
| 347 | latestTurnId: TurnId | string | null | undefined, | |
| 348 | ): LatestProposedPlanState | null { | |
| 349 | if (latestTurnId) { | |
| 350 | const matchingTurnPlan = [...proposedPlans] | |
| 351 | .filter((proposedPlan) => proposedPlan.turnId === latestTurnId) | |
| 352 | .toSorted( | |
| 353 | (left, right) => | |
| 354 | left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id), | |
| 355 | ) | |
| 356 | .at(-1); | |
| 357 | if (matchingTurnPlan) { | |
| 358 | return { | |
| 359 | id: matchingTurnPlan.id, | |
| 360 | createdAt: matchingTurnPlan.createdAt, | |
| 361 | updatedAt: matchingTurnPlan.updatedAt, | |
| 362 | turnId: matchingTurnPlan.turnId, | |
| 363 | planMarkdown: matchingTurnPlan.planMarkdown, | |
| 364 | }; | |
| 365 | } | |
| 366 | } | |
| 367 | ||
| 368 | const latestPlan = [...proposedPlans] | |
| 369 | .toSorted( | |
| 370 | (left, right) => | |
| 371 | left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id), | |
| 372 | ) | |
| 373 | .at(-1); | |
| 374 | if (!latestPlan) { | |
| 375 | return null; | |
| 376 | } | |
| 377 | ||
| 378 | return { | |
| 379 | id: latestPlan.id, | |
| 380 | createdAt: latestPlan.createdAt, | |
| 381 | updatedAt: latestPlan.updatedAt, | |
| 382 | turnId: latestPlan.turnId, | |
| 383 | planMarkdown: latestPlan.planMarkdown, | |
| 384 | }; | |
| 385 | } | |
| 386 | ||
| 330 | 387 | export function deriveWorkLogEntries( |
| 331 | 388 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 332 | 389 | latestTurnId: TurnId | undefined, |
| ... | ... | @@ -381,6 +438,7 @@ export function hasToolActivityForTurn( |
| 381 | 438 | |
| 382 | 439 | export function deriveTimelineEntries( |
| 383 | 440 | messages: ChatMessage[], |
| 441 | proposedPlans: ProposedPlan[], | |
| 384 | 442 | workEntries: WorkLogEntry[], |
| 385 | 443 | ): TimelineEntry[] { |
| 386 | 444 | const messageRows: TimelineEntry[] = messages.map((message) => ({ |
| ... | ... | @@ -389,13 +447,21 @@ export function deriveTimelineEntries( |
| 389 | 447 | createdAt: message.createdAt, |
| 390 | 448 | message, |
| 391 | 449 | })); |
| 450 | const proposedPlanRows: TimelineEntry[] = proposedPlans.map((proposedPlan) => ({ | |
| 451 | id: proposedPlan.id, | |
| 452 | kind: "proposed-plan", | |
| 453 | createdAt: proposedPlan.createdAt, | |
| 454 | proposedPlan, | |
| 455 | })); | |
| 392 | 456 | const workRows: TimelineEntry[] = workEntries.map((entry) => ({ |
| 393 | 457 | id: entry.id, |
| 394 | 458 | kind: "work", |
| 395 | 459 | createdAt: entry.createdAt, |
| 396 | 460 | entry, |
| 397 | 461 | })); |
| 398 | return [...messageRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); | |
| 462 | return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => | |
| 463 | a.createdAt.localeCompare(b.createdAt), | |
| 464 | ); | |
| 399 | 465 | } |
| 400 | 466 | |
| 401 | 467 | export function inferCheckpointTurnCountByTurnId( |
apps/web/src/store.test.ts+2| ... | ... | @@ -17,6 +17,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 17 | 17 | messages: [], |
| 18 | 18 | turnDiffSummaries: [], |
| 19 | 19 | activities: [], |
| 20 | proposedPlans: [], | |
| 20 | 21 | error: null, |
| 21 | 22 | createdAt: "2026-02-13T00:00:00.000Z", |
| 22 | 23 | latestTurn: null, |
| ... | ... | @@ -59,6 +60,7 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads" |
| 59 | 60 | deletedAt: null, |
| 60 | 61 | messages: [], |
| 61 | 62 | activities: [], |
| 63 | proposedPlans: [], | |
| 62 | 64 | checkpoints: [], |
| 63 | 65 | session: null, |
| 64 | 66 | ...overrides, |
apps/web/src/store.ts+7| ... | ... | @@ -289,6 +289,13 @@ export function syncServerReadModel( |
| 289 | 289 | }; |
| 290 | 290 | return normalizedMessage; |
| 291 | 291 | }), |
| 292 | proposedPlans: thread.proposedPlans.map((proposedPlan) => ({ | |
| 293 | id: proposedPlan.id, | |
| 294 | turnId: proposedPlan.turnId, | |
| 295 | planMarkdown: proposedPlan.planMarkdown, | |
| 296 | createdAt: proposedPlan.createdAt, | |
| 297 | updatedAt: proposedPlan.updatedAt, | |
| 298 | })), | |
| 292 | 299 | error: thread.session?.lastError ?? null, |
| 293 | 300 | createdAt: thread.createdAt, |
| 294 | 301 | latestTurn: thread.latestTurn, |
apps/web/src/types.ts+10| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import type { |
| 2 | 2 | OrchestrationLatestTurn, |
| 3 | OrchestrationProposedPlanId, | |
| 3 | 4 | OrchestrationSessionStatus, |
| 4 | 5 | OrchestrationThreadActivity, |
| 5 | 6 | ProjectScript as ContractProjectScript, |
| ... | ... | @@ -48,6 +49,14 @@ export interface ChatMessage { |
| 48 | 49 | streaming: boolean; |
| 49 | 50 | } |
| 50 | 51 | |
| 52 | export interface ProposedPlan { | |
| 53 | id: OrchestrationProposedPlanId; | |
| 54 | turnId: TurnId | null; | |
| 55 | planMarkdown: string; | |
| 56 | createdAt: string; | |
| 57 | updatedAt: string; | |
| 58 | } | |
| 59 | ||
| 51 | 60 | export interface TurnDiffFileChange { |
| 52 | 61 | path: string; |
| 53 | 62 | kind?: string | undefined; |
| ... | ... | @@ -84,6 +93,7 @@ export interface Thread { |
| 84 | 93 | interactionMode: InteractionMode; |
| 85 | 94 | session: ThreadSession | null; |
| 86 | 95 | messages: ChatMessage[]; |
| 96 | proposedPlans: ProposedPlan[]; | |
| 87 | 97 | error: string | null; |
| 88 | 98 | createdAt: string; |
| 89 | 99 | latestTurn: OrchestrationLatestTurn | null; |
apps/web/src/worktreeCleanup.test.ts+1| ... | ... | @@ -17,6 +17,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 17 | 17 | messages: [], |
| 18 | 18 | turnDiffSummaries: [], |
| 19 | 19 | activities: [], |
| 20 | proposedPlans: [], | |
| 20 | 21 | error: null, |
| 21 | 22 | createdAt: "2026-02-13T00:00:00.000Z", |
| 22 | 23 | latestTurn: null, |
packages/contracts/src/orchestration.ts+40| ... | ... | @@ -136,6 +136,18 @@ export const OrchestrationMessage = Schema.Struct({ |
| 136 | 136 | }); |
| 137 | 137 | export type OrchestrationMessage = typeof OrchestrationMessage.Type; |
| 138 | 138 | |
| 139 | export const OrchestrationProposedPlanId = TrimmedNonEmptyString; | |
| 140 | export type OrchestrationProposedPlanId = typeof OrchestrationProposedPlanId.Type; | |
| 141 | ||
| 142 | export const OrchestrationProposedPlan = Schema.Struct({ | |
| 143 | id: OrchestrationProposedPlanId, | |
| 144 | turnId: Schema.NullOr(TurnId), | |
| 145 | planMarkdown: TrimmedNonEmptyString, | |
| 146 | createdAt: IsoDateTime, | |
| 147 | updatedAt: IsoDateTime, | |
| 148 | }); | |
| 149 | export type OrchestrationProposedPlan = typeof OrchestrationProposedPlan.Type; | |
| 150 | ||
| 139 | 151 | export const OrchestrationSessionStatus = Schema.Literals([ |
| 140 | 152 | "idle", |
| 141 | 153 | "starting", |
| ... | ... | @@ -234,6 +246,9 @@ export const OrchestrationThread = Schema.Struct({ |
| 234 | 246 | updatedAt: IsoDateTime, |
| 235 | 247 | deletedAt: Schema.NullOr(IsoDateTime), |
| 236 | 248 | messages: Schema.Array(OrchestrationMessage), |
| 249 | proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( | |
| 250 | Schema.withDecodingDefault(() => []), | |
| 251 | ), | |
| 237 | 252 | activities: Schema.Array(OrchestrationThreadActivity), |
| 238 | 253 | checkpoints: Schema.Array(OrchestrationCheckpointSummary), |
| 239 | 254 | session: Schema.NullOr(OrchestrationSession), |
| ... | ... | @@ -467,6 +482,14 @@ export const ThreadMessageAssistantCompleteCommand = Schema.Struct({ |
| 467 | 482 | createdAt: IsoDateTime, |
| 468 | 483 | }); |
| 469 | 484 | |
| 485 | export const ThreadProposedPlanUpsertCommand = Schema.Struct({ | |
| 486 | type: Schema.Literal("thread.proposed-plan.upsert"), | |
| 487 | commandId: CommandId, | |
| 488 | threadId: ThreadId, | |
| 489 | proposedPlan: OrchestrationProposedPlan, | |
| 490 | createdAt: IsoDateTime, | |
| 491 | }); | |
| 492 | ||
| 470 | 493 | export const ThreadTurnDiffCompleteCommand = Schema.Struct({ |
| 471 | 494 | type: Schema.Literal("thread.turn.diff.complete"), |
| 472 | 495 | commandId: CommandId, |
| ... | ... | @@ -501,6 +524,7 @@ export const InternalOrchestrationCommand = Schema.Union([ |
| 501 | 524 | ThreadSessionSetCommand, |
| 502 | 525 | ThreadMessageAssistantDeltaCommand, |
| 503 | 526 | ThreadMessageAssistantCompleteCommand, |
| 527 | ThreadProposedPlanUpsertCommand, | |
| 504 | 528 | ThreadTurnDiffCompleteCommand, |
| 505 | 529 | ThreadActivityAppendCommand, |
| 506 | 530 | ThreadRevertCompleteCommand, |
| ... | ... | @@ -531,6 +555,7 @@ export const OrchestrationEventType = Schema.Literals([ |
| 531 | 555 | "thread.reverted", |
| 532 | 556 | "thread.session-stop-requested", |
| 533 | 557 | "thread.session-set", |
| 558 | "thread.proposed-plan-upserted", | |
| 534 | 559 | "thread.turn-diff-completed", |
| 535 | 560 | "thread.activity-appended", |
| 536 | 561 | ]); |
| ... | ... | @@ -674,6 +699,11 @@ export const ThreadSessionSetPayload = Schema.Struct({ |
| 674 | 699 | session: OrchestrationSession, |
| 675 | 700 | }); |
| 676 | 701 | |
| 702 | export const ThreadProposedPlanUpsertedPayload = Schema.Struct({ | |
| 703 | threadId: ThreadId, | |
| 704 | proposedPlan: OrchestrationProposedPlan, | |
| 705 | }); | |
| 706 | ||
| 677 | 707 | export const ThreadTurnDiffCompletedPayload = Schema.Struct({ |
| 678 | 708 | threadId: ThreadId, |
| 679 | 709 | turnId: TurnId, |
| ... | ... | @@ -811,6 +841,11 @@ export const OrchestrationEvent = Schema.Union([ |
| 811 | 841 | type: Schema.Literal("thread.session-set"), |
| 812 | 842 | payload: ThreadSessionSetPayload, |
| 813 | 843 | }), |
| 844 | Schema.Struct({ | |
| 845 | ...EventBaseFields, | |
| 846 | type: Schema.Literal("thread.proposed-plan-upserted"), | |
| 847 | payload: ThreadProposedPlanUpsertedPayload, | |
| 848 | }), | |
| 814 | 849 | Schema.Struct({ |
| 815 | 850 | ...EventBaseFields, |
| 816 | 851 | type: Schema.Literal("thread.turn-diff-completed"), |
| ... | ... | @@ -910,6 +945,11 @@ export const OrchestrationPersistedEvent = Schema.Union([ |
| 910 | 945 | eventType: Schema.Literal("thread.session-set"), |
| 911 | 946 | payload: ThreadSessionSetPayload, |
| 912 | 947 | }), |
| 948 | Schema.Struct({ | |
| 949 | ...PersistedEventBaseFields, | |
| 950 | eventType: Schema.Literal("thread.proposed-plan-upserted"), | |
| 951 | payload: ThreadProposedPlanUpsertedPayload, | |
| 952 | }), | |
| 913 | 953 | Schema.Struct({ |
| 914 | 954 | ...PersistedEventBaseFields, |
| 915 | 955 | eventType: Schema.Literal("thread.turn-diff-completed"), |
packages/contracts/src/providerRuntime.test.ts+22| ... | ... | @@ -32,6 +32,26 @@ describe("ProviderRuntimeEvent", () => { |
| 32 | 32 | expect(parsed.payload.plan[1]?.status).toBe("inProgress"); |
| 33 | 33 | }); |
| 34 | 34 | |
| 35 | it("decodes proposed-plan completion events", () => { | |
| 36 | const parsed = decodeRuntimeEvent({ | |
| 37 | type: "turn.proposed.completed", | |
| 38 | eventId: "event-proposed-plan-1", | |
| 39 | provider: "codex", | |
| 40 | createdAt: "2026-02-28T00:00:00.000Z", | |
| 41 | threadId: "thread-1", | |
| 42 | turnId: "turn-1", | |
| 43 | payload: { | |
| 44 | planMarkdown: "# Ship it", | |
| 45 | }, | |
| 46 | }); | |
| 47 | ||
| 48 | expect(parsed.type).toBe("turn.proposed.completed"); | |
| 49 | if (parsed.type !== "turn.proposed.completed") { | |
| 50 | throw new Error("expected turn.proposed.completed"); | |
| 51 | } | |
| 52 | expect(parsed.payload.planMarkdown).toBe("# Ship it"); | |
| 53 | }); | |
| 54 | ||
| 35 | 55 | it("decodes user-input.requested with structured questions", () => { |
| 36 | 56 | const parsed = decodeRuntimeEvent({ |
| 37 | 57 | type: "user-input.requested", |
| ... | ... | @@ -39,6 +59,7 @@ describe("ProviderRuntimeEvent", () => { |
| 39 | 59 | provider: "claudeCode", |
| 40 | 60 | sessionId: "runtime-session-2", |
| 41 | 61 | createdAt: "2026-02-28T00:00:01.000Z", |
| 62 | threadId: "thread-2", | |
| 42 | 63 | requestId: "request-1", |
| 43 | 64 | payload: { |
| 44 | 65 | questions: [ |
| ... | ... | @@ -76,6 +97,7 @@ describe("ProviderRuntimeEvent", () => { |
| 76 | 97 | provider: "claudeCode", |
| 77 | 98 | sessionId: "runtime-session-2", |
| 78 | 99 | createdAt: "2026-02-28T00:00:02.000Z", |
| 100 | threadId: "thread-2", | |
| 79 | 101 | requestId: "request-1", |
| 80 | 102 | payload: { |
| 81 | 103 | answers: { |
packages/contracts/src/providerRuntime.ts+32| ... | ... | @@ -154,6 +154,8 @@ export const ProviderRuntimeEventType = Schema.Literals([ |
| 154 | 154 | "turn.completed", |
| 155 | 155 | "turn.aborted", |
| 156 | 156 | "turn.plan.updated", |
| 157 | "turn.proposed.delta", | |
| 158 | "turn.proposed.completed", | |
| 157 | 159 | "turn.diff.updated", |
| 158 | 160 | "item.started", |
| 159 | 161 | "item.updated", |
| ... | ... | @@ -202,6 +204,8 @@ export const TurnStartedType = Schema.Literal("turn.started"); |
| 202 | 204 | export const TurnCompletedType = Schema.Literal("turn.completed"); |
| 203 | 205 | export const TurnAbortedType = Schema.Literal("turn.aborted"); |
| 204 | 206 | export const TurnPlanUpdatedType = Schema.Literal("turn.plan.updated"); |
| 207 | export const TurnProposedDeltaType = Schema.Literal("turn.proposed.delta"); | |
| 208 | export const TurnProposedCompletedType = Schema.Literal("turn.proposed.completed"); | |
| 205 | 209 | export const TurnDiffUpdatedType = Schema.Literal("turn.diff.updated"); |
| 206 | 210 | export const ItemStartedType = Schema.Literal("item.started"); |
| 207 | 211 | export const ItemUpdatedType = Schema.Literal("item.updated"); |
| ... | ... | @@ -349,6 +353,16 @@ export const TurnPlanUpdatedPayload = Schema.Struct({ |
| 349 | 353 | }); |
| 350 | 354 | export type TurnPlanUpdatedPayload = typeof TurnPlanUpdatedPayload.Type; |
| 351 | 355 | |
| 356 | export const TurnProposedDeltaPayload = Schema.Struct({ | |
| 357 | delta: Schema.String, | |
| 358 | }); | |
| 359 | export type TurnProposedDeltaPayload = typeof TurnProposedDeltaPayload.Type; | |
| 360 | ||
| 361 | export const TurnProposedCompletedPayload = Schema.Struct({ | |
| 362 | planMarkdown: TrimmedNonEmptyStringSchema, | |
| 363 | }); | |
| 364 | export type TurnProposedCompletedPayload = typeof TurnProposedCompletedPayload.Type; | |
| 365 | ||
| 352 | 366 | export const TurnDiffUpdatedPayload = Schema.Struct({ |
| 353 | 367 | unifiedDiff: Schema.String, |
| 354 | 368 | }); |
| ... | ... | @@ -677,6 +691,22 @@ export const ProviderRuntimeTurnPlanUpdatedEvent = Schema.Struct({ |
| 677 | 691 | }); |
| 678 | 692 | export type ProviderRuntimeTurnPlanUpdatedEvent = typeof ProviderRuntimeTurnPlanUpdatedEvent.Type; |
| 679 | 693 | |
| 694 | export const ProviderRuntimeTurnProposedDeltaEvent = Schema.Struct({ | |
| 695 | ...ProviderRuntimeEventBase.fields, | |
| 696 | type: TurnProposedDeltaType, | |
| 697 | payload: TurnProposedDeltaPayload, | |
| 698 | }); | |
| 699 | export type ProviderRuntimeTurnProposedDeltaEvent = | |
| 700 | typeof ProviderRuntimeTurnProposedDeltaEvent.Type; | |
| 701 | ||
| 702 | export const ProviderRuntimeTurnProposedCompletedEvent = Schema.Struct({ | |
| 703 | ...ProviderRuntimeEventBase.fields, | |
| 704 | type: TurnProposedCompletedType, | |
| 705 | payload: TurnProposedCompletedPayload, | |
| 706 | }); | |
| 707 | export type ProviderRuntimeTurnProposedCompletedEvent = | |
| 708 | typeof ProviderRuntimeTurnProposedCompletedEvent.Type; | |
| 709 | ||
| 680 | 710 | export const ProviderRuntimeTurnDiffUpdatedEvent = Schema.Struct({ |
| 681 | 711 | ...ProviderRuntimeEventBase.fields, |
| 682 | 712 | type: TurnDiffUpdatedType, |
| ... | ... | @@ -893,6 +923,8 @@ export const ProviderRuntimeEventV2 = Schema.Union([ |
| 893 | 923 | ProviderRuntimeTurnCompletedEvent, |
| 894 | 924 | ProviderRuntimeTurnAbortedEvent, |
| 895 | 925 | ProviderRuntimeTurnPlanUpdatedEvent, |
| 926 | ProviderRuntimeTurnProposedDeltaEvent, | |
| 927 | ProviderRuntimeTurnProposedCompletedEvent, | |
| 896 | 928 | ProviderRuntimeTurnDiffUpdatedEvent, |
| 897 | 929 | ProviderRuntimeItemStartedEvent, |
| 898 | 930 | ProviderRuntimeItemUpdatedEvent, |