authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-03-19 01:39:57+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-18 13:09:57-07:00
log0de5742b04887439d827bb51d10c2e3e849f67ea
tree85aa238cb51a13cc0001079018950af4efa18188
parentb500a36a94524f9829b0675e602789c4a56f6477
signature Signed by PGP key B5690EEEBB952194

fix: consume plans on implementation (#1133)


23 files changed, 965 insertions(+), 16 deletions(-)

apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4
...@@ -653,6 +653,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -653,6 +653,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
653 threadId: event.payload.threadId,653 threadId: event.payload.threadId,
654 turnId: event.payload.proposedPlan.turnId,654 turnId: event.payload.proposedPlan.turnId,
655 planMarkdown: event.payload.proposedPlan.planMarkdown,655 planMarkdown: event.payload.proposedPlan.planMarkdown,
656 implementedAt: event.payload.proposedPlan.implementedAt,
657 implementationThreadId: event.payload.proposedPlan.implementationThreadId,
656 createdAt: event.payload.proposedPlan.createdAt,658 createdAt: event.payload.proposedPlan.createdAt,
657 updatedAt: event.payload.proposedPlan.updatedAt,659 updatedAt: event.payload.proposedPlan.updatedAt,
658 });660 });
...@@ -775,6 +777,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -775,6 +777,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
775 yield* projectionTurnRepository.replacePendingTurnStart({777 yield* projectionTurnRepository.replacePendingTurnStart({
776 threadId: event.payload.threadId,778 threadId: event.payload.threadId,
777 messageId: event.payload.messageId,779 messageId: event.payload.messageId,
780 sourceProposedPlanThreadId: event.payload.sourceProposedPlan?.threadId ?? null,
781 sourceProposedPlanId: event.payload.sourceProposedPlan?.planId ?? null,
778 requestedAt: event.payload.createdAt,782 requestedAt: event.payload.createdAt,
779 });783 });
780 return;784 return;
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+35-1
...@@ -26,6 +26,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -26,6 +26,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
2626
27 yield* sql`DELETE FROM projection_projects`;27 yield* sql`DELETE FROM projection_projects`;
28 yield* sql`DELETE FROM projection_state`;28 yield* sql`DELETE FROM projection_state`;
29 yield* sql`DELETE FROM projection_thread_proposed_plans`;
29 yield* sql`DELETE FROM projection_turns`;30 yield* sql`DELETE FROM projection_turns`;
3031
31 yield* sql`32 yield* sql`
...@@ -101,6 +102,29 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -101,6 +102,29 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
101 )102 )
102 `;103 `;
103104
105 yield* sql`
106 INSERT INTO projection_thread_proposed_plans (
107 plan_id,
108 thread_id,
109 turn_id,
110 plan_markdown,
111 implemented_at,
112 implementation_thread_id,
113 created_at,
114 updated_at
115 )
116 VALUES (
117 'plan-1',
118 'thread-1',
119 'turn-1',
120 '# Ship it',
121 '2026-02-24T00:00:05.500Z',
122 'thread-2',
123 '2026-02-24T00:00:05.000Z',
124 '2026-02-24T00:00:05.500Z'
125 )
126 `;
127
104 yield* sql`128 yield* sql`
105 INSERT INTO projection_thread_activities (129 INSERT INTO projection_thread_activities (
106 activity_id,130 activity_id,
...@@ -253,7 +277,17 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -253,7 +277,17 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
253 updatedAt: "2026-02-24T00:00:05.000Z",277 updatedAt: "2026-02-24T00:00:05.000Z",
254 },278 },
255 ],279 ],
256 proposedPlans: [],280 proposedPlans: [
281 {
282 id: "plan-1",
283 turnId: asTurnId("turn-1"),
284 planMarkdown: "# Ship it",
285 implementedAt: "2026-02-24T00:00:05.500Z",
286 implementationThreadId: ThreadId.makeUnsafe("thread-2"),
287 createdAt: "2026-02-24T00:00:05.000Z",
288 updatedAt: "2026-02-24T00:00:05.500Z",
289 },
290 ],
257 activities: [291 activities: [
258 {292 {
259 id: asEventId("activity-1"),293 id: asEventId("activity-1"),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+4
...@@ -200,6 +200,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -200,6 +200,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
200 thread_id AS "threadId",200 thread_id AS "threadId",
201 turn_id AS "turnId",201 turn_id AS "turnId",
202 plan_markdown AS "planMarkdown",202 plan_markdown AS "planMarkdown",
203 implemented_at AS "implementedAt",
204 implementation_thread_id AS "implementationThreadId",
203 created_at AS "createdAt",205 created_at AS "createdAt",
204 updated_at AS "updatedAt"206 updated_at AS "updatedAt"
205 FROM projection_thread_proposed_plans207 FROM projection_thread_proposed_plans
...@@ -435,6 +437,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -435,6 +437,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
435 id: row.planId,437 id: row.planId,
436 turnId: row.turnId,438 turnId: row.turnId,
437 planMarkdown: row.planMarkdown,439 planMarkdown: row.planMarkdown,
440 implementedAt: row.implementedAt,
441 implementationThreadId: row.implementationThreadId,
438 createdAt: row.createdAt,442 createdAt: row.createdAt,
439 updatedAt: row.updatedAt,443 updatedAt: row.updatedAt,
440 });444 });
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+519-3
...@@ -2,7 +2,11 @@ import fs from "node:fs";...@@ -2,7 +2,11 @@ import fs from "node:fs";
2import os from "node:os";2import os from "node:os";
3import path from "node:path";3import path from "node:path";
44
5import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts";5import type {
6 OrchestrationReadModel,
7 ProviderRuntimeEvent,
8 ProviderSession,
9} from "@t3tools/contracts";
6import {10import {
7 ApprovalRequestId,11 ApprovalRequestId,
8 CommandId,12 CommandId,
...@@ -57,6 +61,7 @@ type LegacyProviderRuntimeEvent = {...@@ -57,6 +61,7 @@ type LegacyProviderRuntimeEvent = {
5761
58function createProviderServiceHarness() {62function createProviderServiceHarness() {
59 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());63 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
64 const runtimeSessions: ProviderSession[] = [];
6065
61 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;66 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
62 const service: ProviderServiceShape = {67 const service: ProviderServiceShape = {
...@@ -66,12 +71,21 @@ function createProviderServiceHarness() {...@@ -66,12 +71,21 @@ function createProviderServiceHarness() {
66 respondToRequest: () => unsupported(),71 respondToRequest: () => unsupported(),
67 respondToUserInput: () => unsupported(),72 respondToUserInput: () => unsupported(),
68 stopSession: () => unsupported(),73 stopSession: () => unsupported(),
69 listSessions: () => Effect.succeed([]),74 listSessions: () => Effect.succeed([...runtimeSessions]),
70 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),75 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
71 rollbackConversation: () => unsupported(),76 rollbackConversation: () => unsupported(),
72 streamEvents: Stream.fromPubSub(runtimeEventPubSub),77 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
73 };78 };
7479
80 const setSession = (session: ProviderSession): void => {
81 const existingIndex = runtimeSessions.findIndex((entry) => entry.threadId === session.threadId);
82 if (existingIndex >= 0) {
83 runtimeSessions[existingIndex] = session;
84 return;
85 }
86 runtimeSessions.push(session);
87 };
88
75 const emit = (event: LegacyProviderRuntimeEvent): void => {89 const emit = (event: LegacyProviderRuntimeEvent): void => {
76 Effect.runSync(PubSub.publish(runtimeEventPubSub, event as unknown as ProviderRuntimeEvent));90 Effect.runSync(PubSub.publish(runtimeEventPubSub, event as unknown as ProviderRuntimeEvent));
77 };91 };
...@@ -79,6 +93,7 @@ function createProviderServiceHarness() {...@@ -79,6 +93,7 @@ function createProviderServiceHarness() {
79 return {93 return {
80 service,94 service,
81 emit,95 emit,
96 setSession,
82 };97 };
83}98}
8499
...@@ -86,11 +101,12 @@ async function waitForThread(...@@ -86,11 +101,12 @@ async function waitForThread(
86 engine: OrchestrationEngineShape,101 engine: OrchestrationEngineShape,
87 predicate: (thread: ProviderRuntimeTestThread) => boolean,102 predicate: (thread: ProviderRuntimeTestThread) => boolean,
88 timeoutMs = 2000,103 timeoutMs = 2000,
104 threadId: ThreadId = asThreadId("thread-1"),
89) {105) {
90 const deadline = Date.now() + timeoutMs;106 const deadline = Date.now() + timeoutMs;
91 const poll = async (): Promise<ProviderRuntimeTestThread> => {107 const poll = async (): Promise<ProviderRuntimeTestThread> => {
92 const readModel = await Effect.runPromise(engine.getReadModel());108 const readModel = await Effect.runPromise(engine.getReadModel());
93 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));109 const thread = readModel.threads.find((entry) => entry.id === threadId);
94 if (thread && predicate(thread)) {110 if (thread && predicate(thread)) {
95 return thread;111 return thread;
96 }112 }
...@@ -150,6 +166,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -150,6 +166,7 @@ describe("ProviderRuntimeIngestion", () => {
150 );166 );
151 const layer = ProviderRuntimeIngestionLive.pipe(167 const layer = ProviderRuntimeIngestionLive.pipe(
152 Layer.provideMerge(orchestrationLayer),168 Layer.provideMerge(orchestrationLayer),
169 Layer.provideMerge(SqlitePersistenceMemory),
153 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),170 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
154 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),171 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
155 Layer.provideMerge(NodeServices.layer),172 Layer.provideMerge(NodeServices.layer),
...@@ -205,10 +222,19 @@ describe("ProviderRuntimeIngestion", () => {...@@ -205,10 +222,19 @@ describe("ProviderRuntimeIngestion", () => {
205 createdAt,222 createdAt,
206 }),223 }),
207 );224 );
225 provider.setSession({
226 provider: "codex",
227 status: "ready",
228 runtimeMode: "approval-required",
229 threadId: ThreadId.makeUnsafe("thread-1"),
230 createdAt,
231 updatedAt: createdAt,
232 });
208233
209 return {234 return {
210 engine,235 engine,
211 emit: provider.emit,236 emit: provider.emit,
237 setProviderSession: provider.setSession,
212 drain,238 drain,
213 };239 };
214 }240 }
...@@ -628,6 +654,496 @@ describe("ProviderRuntimeIngestion", () => {...@@ -628,6 +654,496 @@ describe("ProviderRuntimeIngestion", () => {
628 );654 );
629 });655 });
630656
657 it("marks the source proposed plan implemented only after the target turn starts", async () => {
658 const harness = await createHarness();
659 const sourceThreadId = asThreadId("thread-plan");
660 const targetThreadId = asThreadId("thread-implement");
661 const sourceTurnId = asTurnId("turn-plan-source");
662 const targetTurnId = asTurnId("turn-plan-implement");
663 const createdAt = new Date().toISOString();
664
665 await Effect.runPromise(
666 harness.engine.dispatch({
667 type: "thread.create",
668 commandId: CommandId.makeUnsafe("cmd-thread-create-plan-source"),
669 threadId: sourceThreadId,
670 projectId: asProjectId("project-1"),
671 title: "Plan Source",
672 model: "gpt-5-codex",
673 interactionMode: "plan",
674 runtimeMode: "approval-required",
675 branch: null,
676 worktreePath: null,
677 createdAt,
678 }),
679 );
680 await Effect.runPromise(
681 harness.engine.dispatch({
682 type: "thread.session.set",
683 commandId: CommandId.makeUnsafe("cmd-session-set-plan-source"),
684 threadId: sourceThreadId,
685 session: {
686 threadId: sourceThreadId,
687 status: "ready",
688 providerName: "codex",
689 runtimeMode: "approval-required",
690 activeTurnId: null,
691 updatedAt: createdAt,
692 lastError: null,
693 },
694 createdAt,
695 }),
696 );
697 await Effect.runPromise(
698 harness.engine.dispatch({
699 type: "thread.create",
700 commandId: CommandId.makeUnsafe("cmd-thread-create-plan-target"),
701 threadId: targetThreadId,
702 projectId: asProjectId("project-1"),
703 title: "Plan Target",
704 model: "gpt-5-codex",
705 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
706 runtimeMode: "approval-required",
707 branch: null,
708 worktreePath: null,
709 createdAt,
710 }),
711 );
712 await Effect.runPromise(
713 harness.engine.dispatch({
714 type: "thread.session.set",
715 commandId: CommandId.makeUnsafe("cmd-session-set-plan-target"),
716 threadId: targetThreadId,
717 session: {
718 threadId: targetThreadId,
719 status: "ready",
720 providerName: "codex",
721 runtimeMode: "approval-required",
722 activeTurnId: null,
723 updatedAt: createdAt,
724 lastError: null,
725 },
726 createdAt,
727 }),
728 );
729 harness.setProviderSession({
730 provider: "codex",
731 status: "ready",
732 runtimeMode: "approval-required",
733 threadId: targetThreadId,
734 createdAt,
735 updatedAt: createdAt,
736 activeTurnId: targetTurnId,
737 });
738
739 harness.emit({
740 type: "turn.proposed.completed",
741 eventId: asEventId("evt-plan-source-completed"),
742 provider: "codex",
743 createdAt,
744 threadId: sourceThreadId,
745 turnId: sourceTurnId,
746 payload: {
747 planMarkdown: "# Source plan",
748 },
749 });
750
751 const sourceThreadWithPlan = await waitForThread(
752 harness.engine,
753 (thread) =>
754 thread.proposedPlans.some(
755 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
756 proposedPlan.id === "plan:thread-plan:turn:turn-plan-source" &&
757 proposedPlan.implementedAt === null,
758 ),
759 2_000,
760 sourceThreadId,
761 );
762 const sourcePlan = sourceThreadWithPlan.proposedPlans.find(
763 (entry: ProviderRuntimeTestProposedPlan) =>
764 entry.id === "plan:thread-plan:turn:turn-plan-source",
765 );
766 expect(sourcePlan).toBeDefined();
767 if (!sourcePlan) {
768 throw new Error("Expected source plan to exist.");
769 }
770
771 await Effect.runPromise(
772 harness.engine.dispatch({
773 type: "thread.turn.start",
774 commandId: CommandId.makeUnsafe("cmd-turn-start-plan-target"),
775 threadId: targetThreadId,
776 message: {
777 messageId: asMessageId("msg-plan-target"),
778 role: "user",
779 text: "PLEASE IMPLEMENT THIS PLAN:\n# Source plan",
780 attachments: [],
781 },
782 sourceProposedPlan: {
783 threadId: sourceThreadId,
784 planId: sourcePlan.id,
785 },
786 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
787 runtimeMode: "approval-required",
788 createdAt: new Date().toISOString(),
789 }),
790 );
791
792 const sourceThreadBeforeStart = await waitForThread(
793 harness.engine,
794 (thread) =>
795 thread.proposedPlans.some(
796 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
797 proposedPlan.id === sourcePlan.id && proposedPlan.implementedAt === null,
798 ),
799 2_000,
800 sourceThreadId,
801 );
802 expect(
803 sourceThreadBeforeStart.proposedPlans.find((entry) => entry.id === sourcePlan.id),
804 ).toMatchObject({
805 implementedAt: null,
806 implementationThreadId: null,
807 });
808
809 harness.emit({
810 type: "turn.started",
811 eventId: asEventId("evt-plan-target-started"),
812 provider: "codex",
813 createdAt: new Date().toISOString(),
814 threadId: targetThreadId,
815 turnId: targetTurnId,
816 });
817
818 const sourceThreadAfterStart = await waitForThread(
819 harness.engine,
820 (thread) =>
821 thread.proposedPlans.some(
822 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
823 proposedPlan.id === sourcePlan.id &&
824 proposedPlan.implementedAt !== null &&
825 proposedPlan.implementationThreadId === targetThreadId,
826 ),
827 2_000,
828 sourceThreadId,
829 );
830 expect(
831 sourceThreadAfterStart.proposedPlans.find((entry) => entry.id === sourcePlan.id),
832 ).toMatchObject({
833 implementationThreadId: "thread-implement",
834 });
835 });
836
837 it("does not mark the source proposed plan implemented for a rejected turn.started event", async () => {
838 const harness = await createHarness();
839 const sourceThreadId = asThreadId("thread-plan");
840 const targetThreadId = asThreadId("thread-1");
841 const sourceTurnId = asTurnId("turn-plan-source");
842 const activeTurnId = asTurnId("turn-already-running");
843 const staleTurnId = asTurnId("turn-stale-start");
844 const createdAt = new Date().toISOString();
845
846 await Effect.runPromise(
847 harness.engine.dispatch({
848 type: "thread.create",
849 commandId: CommandId.makeUnsafe("cmd-thread-create-plan-source-guarded"),
850 threadId: sourceThreadId,
851 projectId: asProjectId("project-1"),
852 title: "Plan Source",
853 model: "gpt-5-codex",
854 interactionMode: "plan",
855 runtimeMode: "approval-required",
856 branch: null,
857 worktreePath: null,
858 createdAt,
859 }),
860 );
861 await Effect.runPromise(
862 harness.engine.dispatch({
863 type: "thread.session.set",
864 commandId: CommandId.makeUnsafe("cmd-session-set-plan-source-guarded"),
865 threadId: sourceThreadId,
866 session: {
867 threadId: sourceThreadId,
868 status: "ready",
869 providerName: "codex",
870 runtimeMode: "approval-required",
871 activeTurnId: null,
872 updatedAt: createdAt,
873 lastError: null,
874 },
875 createdAt,
876 }),
877 );
878 harness.setProviderSession({
879 provider: "codex",
880 status: "running",
881 runtimeMode: "approval-required",
882 threadId: targetThreadId,
883 createdAt,
884 updatedAt: createdAt,
885 activeTurnId,
886 });
887
888 harness.emit({
889 type: "turn.started",
890 eventId: asEventId("evt-turn-started-already-running"),
891 provider: "codex",
892 createdAt,
893 threadId: targetThreadId,
894 turnId: activeTurnId,
895 });
896
897 await waitForThread(
898 harness.engine,
899 (thread) =>
900 thread.session?.status === "running" && thread.session?.activeTurnId === activeTurnId,
901 2_000,
902 targetThreadId,
903 );
904
905 harness.emit({
906 type: "turn.proposed.completed",
907 eventId: asEventId("evt-plan-source-completed-guarded"),
908 provider: "codex",
909 createdAt,
910 threadId: sourceThreadId,
911 turnId: sourceTurnId,
912 payload: {
913 planMarkdown: "# Source plan",
914 },
915 });
916
917 const sourceThreadWithPlan = await waitForThread(
918 harness.engine,
919 (thread) =>
920 thread.proposedPlans.some(
921 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
922 proposedPlan.id === "plan:thread-plan:turn:turn-plan-source" &&
923 proposedPlan.implementedAt === null,
924 ),
925 2_000,
926 sourceThreadId,
927 );
928 const sourcePlan = sourceThreadWithPlan.proposedPlans.find(
929 (entry: ProviderRuntimeTestProposedPlan) =>
930 entry.id === "plan:thread-plan:turn:turn-plan-source",
931 );
932 expect(sourcePlan).toBeDefined();
933 if (!sourcePlan) {
934 throw new Error("Expected source plan to exist.");
935 }
936
937 await Effect.runPromise(
938 harness.engine.dispatch({
939 type: "thread.turn.start",
940 commandId: CommandId.makeUnsafe("cmd-turn-start-plan-target-guarded"),
941 threadId: targetThreadId,
942 message: {
943 messageId: asMessageId("msg-plan-target-guarded"),
944 role: "user",
945 text: "PLEASE IMPLEMENT THIS PLAN:\n# Source plan",
946 attachments: [],
947 },
948 sourceProposedPlan: {
949 threadId: sourceThreadId,
950 planId: sourcePlan.id,
951 },
952 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
953 runtimeMode: "approval-required",
954 createdAt: new Date().toISOString(),
955 }),
956 );
957
958 harness.emit({
959 type: "turn.started",
960 eventId: asEventId("evt-turn-started-stale-plan-implementation"),
961 provider: "codex",
962 createdAt: new Date().toISOString(),
963 threadId: targetThreadId,
964 turnId: staleTurnId,
965 });
966
967 await harness.drain();
968
969 const readModel = await Effect.runPromise(harness.engine.getReadModel());
970 const sourceThreadAfterRejectedStart = readModel.threads.find(
971 (entry) => entry.id === sourceThreadId,
972 );
973 expect(
974 sourceThreadAfterRejectedStart?.proposedPlans.find((entry) => entry.id === sourcePlan.id),
975 ).toMatchObject({
976 implementedAt: null,
977 implementationThreadId: null,
978 });
979
980 const targetThreadAfterRejectedStart = readModel.threads.find(
981 (entry) => entry.id === targetThreadId,
982 );
983 expect(targetThreadAfterRejectedStart?.session?.status).toBe("running");
984 expect(targetThreadAfterRejectedStart?.session?.activeTurnId).toBe(activeTurnId);
985 });
986
987 it("does not mark the source proposed plan implemented for an unrelated turn.started when no thread active turn is tracked", async () => {
988 const harness = await createHarness();
989 const sourceThreadId = asThreadId("thread-plan");
990 const targetThreadId = asThreadId("thread-implement");
991 const sourceTurnId = asTurnId("turn-plan-source");
992 const expectedTurnId = asTurnId("turn-plan-implement");
993 const replayedTurnId = asTurnId("turn-replayed");
994 const createdAt = new Date().toISOString();
995
996 await Effect.runPromise(
997 harness.engine.dispatch({
998 type: "thread.create",
999 commandId: CommandId.makeUnsafe("cmd-thread-create-plan-source-unrelated"),
1000 threadId: sourceThreadId,
1001 projectId: asProjectId("project-1"),
1002 title: "Plan Source",
1003 model: "gpt-5-codex",
1004 interactionMode: "plan",
1005 runtimeMode: "approval-required",
1006 branch: null,
1007 worktreePath: null,
1008 createdAt,
1009 }),
1010 );
1011 await Effect.runPromise(
1012 harness.engine.dispatch({
1013 type: "thread.session.set",
1014 commandId: CommandId.makeUnsafe("cmd-session-set-plan-source-unrelated"),
1015 threadId: sourceThreadId,
1016 session: {
1017 threadId: sourceThreadId,
1018 status: "ready",
1019 providerName: "codex",
1020 runtimeMode: "approval-required",
1021 activeTurnId: null,
1022 updatedAt: createdAt,
1023 lastError: null,
1024 },
1025 createdAt,
1026 }),
1027 );
1028 await Effect.runPromise(
1029 harness.engine.dispatch({
1030 type: "thread.create",
1031 commandId: CommandId.makeUnsafe("cmd-thread-create-plan-target-unrelated"),
1032 threadId: targetThreadId,
1033 projectId: asProjectId("project-1"),
1034 title: "Plan Target",
1035 model: "gpt-5-codex",
1036 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1037 runtimeMode: "approval-required",
1038 branch: null,
1039 worktreePath: null,
1040 createdAt,
1041 }),
1042 );
1043 await Effect.runPromise(
1044 harness.engine.dispatch({
1045 type: "thread.session.set",
1046 commandId: CommandId.makeUnsafe("cmd-session-set-plan-target-unrelated"),
1047 threadId: targetThreadId,
1048 session: {
1049 threadId: targetThreadId,
1050 status: "ready",
1051 providerName: "codex",
1052 runtimeMode: "approval-required",
1053 activeTurnId: null,
1054 updatedAt: createdAt,
1055 lastError: null,
1056 },
1057 createdAt,
1058 }),
1059 );
1060
1061 harness.emit({
1062 type: "turn.proposed.completed",
1063 eventId: asEventId("evt-plan-source-completed-unrelated"),
1064 provider: "codex",
1065 createdAt,
1066 threadId: sourceThreadId,
1067 turnId: sourceTurnId,
1068 payload: {
1069 planMarkdown: "# Source plan",
1070 },
1071 });
1072
1073 const sourceThreadWithPlan = await waitForThread(
1074 harness.engine,
1075 (thread) =>
1076 thread.proposedPlans.some(
1077 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
1078 proposedPlan.id === "plan:thread-plan:turn:turn-plan-source" &&
1079 proposedPlan.implementedAt === null,
1080 ),
1081 2_000,
1082 sourceThreadId,
1083 );
1084 const sourcePlan = sourceThreadWithPlan.proposedPlans.find(
1085 (entry: ProviderRuntimeTestProposedPlan) =>
1086 entry.id === "plan:thread-plan:turn:turn-plan-source",
1087 );
1088 expect(sourcePlan).toBeDefined();
1089 if (!sourcePlan) {
1090 throw new Error("Expected source plan to exist.");
1091 }
1092
1093 await Effect.runPromise(
1094 harness.engine.dispatch({
1095 type: "thread.turn.start",
1096 commandId: CommandId.makeUnsafe("cmd-turn-start-plan-target-unrelated"),
1097 threadId: targetThreadId,
1098 message: {
1099 messageId: asMessageId("msg-plan-target-unrelated"),
1100 role: "user",
1101 text: "PLEASE IMPLEMENT THIS PLAN:\n# Source plan",
1102 attachments: [],
1103 },
1104 sourceProposedPlan: {
1105 threadId: sourceThreadId,
1106 planId: sourcePlan.id,
1107 },
1108 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1109 runtimeMode: "approval-required",
1110 createdAt: new Date().toISOString(),
1111 }),
1112 );
1113
1114 harness.setProviderSession({
1115 provider: "codex",
1116 status: "running",
1117 runtimeMode: "approval-required",
1118 threadId: targetThreadId,
1119 createdAt,
1120 updatedAt: createdAt,
1121 activeTurnId: expectedTurnId,
1122 });
1123
1124 harness.emit({
1125 type: "turn.started",
1126 eventId: asEventId("evt-turn-started-unrelated-plan-implementation"),
1127 provider: "codex",
1128 createdAt: new Date().toISOString(),
1129 threadId: targetThreadId,
1130 turnId: replayedTurnId,
1131 });
1132
1133 await harness.drain();
1134
1135 const readModel = await Effect.runPromise(harness.engine.getReadModel());
1136 const sourceThreadAfterUnrelatedStart = readModel.threads.find(
1137 (entry) => entry.id === sourceThreadId,
1138 );
1139 expect(
1140 sourceThreadAfterUnrelatedStart?.proposedPlans.find((entry) => entry.id === sourcePlan.id),
1141 ).toMatchObject({
1142 implementedAt: null,
1143 implementationThreadId: null,
1144 });
1145 });
1146
631 it("finalizes buffered proposed-plan deltas into a first-class proposed plan on turn completion", async () => {1147 it("finalizes buffered proposed-plan deltas into a first-class proposed plan on turn completion", async () => {
632 const harness = await createHarness();1148 const harness = await createHarness();
633 const now = new Date().toISOString();1149 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+111-1
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 CommandId,4 CommandId,
5 MessageId,5 MessageId,
6 type OrchestrationEvent,6 type OrchestrationEvent,
7 type OrchestrationProposedPlanId,
7 CheckpointRef,8 CheckpointRef,
8 isToolLifecycleItemType,9 isToolLifecycleItemType,
9 ThreadId,10 ThreadId,
...@@ -15,6 +16,8 @@ import { Cache, Cause, Duration, Effect, Layer, Option, Ref, Stream } from "effe...@@ -15,6 +16,8 @@ import { Cache, Cause, Duration, Effect, Layer, Option, Ref, Stream } from "effe
15import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";16import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
1617
17import { ProviderService } from "../../provider/Services/ProviderService.ts";18import { ProviderService } from "../../provider/Services/ProviderService.ts";
19import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
20import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
18import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";21import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
19import { isGitRepository } from "../../git/isRepo.ts";22import { isGitRepository } from "../../git/isRepo.ts";
20import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";23import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
...@@ -480,6 +483,7 @@ function runtimeEventToActivities(...@@ -480,6 +483,7 @@ function runtimeEventToActivities(
480const make = Effect.gen(function* () {483const make = Effect.gen(function* () {
481 const orchestrationEngine = yield* OrchestrationEngineService;484 const orchestrationEngine = yield* OrchestrationEngineService;
482 const providerService = yield* ProviderService;485 const providerService = yield* ProviderService;
486 const projectionTurnRepository = yield* ProjectionTurnRepository;
483487
484 const assistantDeliveryModeRef = yield* Ref.make<AssistantDeliveryMode>(488 const assistantDeliveryModeRef = yield* Ref.make<AssistantDeliveryMode>(
485 DEFAULT_ASSISTANT_DELIVERY_MODE,489 DEFAULT_ASSISTANT_DELIVERY_MODE,
...@@ -671,6 +675,8 @@ const make = Effect.gen(function* () {...@@ -671,6 +675,8 @@ const make = Effect.gen(function* () {
671 threadProposedPlans: ReadonlyArray<{675 threadProposedPlans: ReadonlyArray<{
672 id: string;676 id: string;
673 createdAt: string;677 createdAt: string;
678 implementedAt: string | null;
679 implementationThreadId: ThreadId | null;
674 }>;680 }>;
675 planId: string;681 planId: string;
676 turnId?: TurnId;682 turnId?: TurnId;
...@@ -693,6 +699,8 @@ const make = Effect.gen(function* () {...@@ -693,6 +699,8 @@ const make = Effect.gen(function* () {
693 id: input.planId,699 id: input.planId,
694 turnId: input.turnId ?? null,700 turnId: input.turnId ?? null,
695 planMarkdown,701 planMarkdown,
702 implementedAt: existingPlan?.implementedAt ?? null,
703 implementationThreadId: existingPlan?.implementationThreadId ?? null,
696 createdAt: existingPlan?.createdAt ?? input.createdAt,704 createdAt: existingPlan?.createdAt ?? input.createdAt,
697 updatedAt: input.updatedAt,705 updatedAt: input.updatedAt,
698 },706 },
...@@ -706,6 +714,8 @@ const make = Effect.gen(function* () {...@@ -706,6 +714,8 @@ const make = Effect.gen(function* () {
706 threadProposedPlans: ReadonlyArray<{714 threadProposedPlans: ReadonlyArray<{
707 id: string;715 id: string;
708 createdAt: string;716 createdAt: string;
717 implementedAt: string | null;
718 implementationThreadId: ThreadId | null;
709 }>;719 }>;
710 planId: string;720 planId: string;
711 turnId?: TurnId;721 turnId?: TurnId;
...@@ -772,6 +782,79 @@ const make = Effect.gen(function* () {...@@ -772,6 +782,79 @@ const make = Effect.gen(function* () {
772 ).pipe(Effect.asVoid);782 ).pipe(Effect.asVoid);
773 });783 });
774784
785 const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fnUntraced(function* (
786 threadId: ThreadId,
787 ) {
788 const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({
789 threadId,
790 });
791 if (Option.isNone(pendingTurnStart)) {
792 return null;
793 }
794
795 const sourceThreadId = pendingTurnStart.value.sourceProposedPlanThreadId;
796 const sourcePlanId = pendingTurnStart.value.sourceProposedPlanId;
797 if (sourceThreadId === null || sourcePlanId === null) {
798 return null;
799 }
800
801 return {
802 sourceThreadId,
803 sourcePlanId,
804 } as const;
805 });
806
807 const getExpectedProviderTurnIdForThread = Effect.fnUntraced(function* (threadId: ThreadId) {
808 const sessions = yield* providerService.listSessions();
809 const session = sessions.find((entry) => entry.threadId === threadId);
810 return session?.activeTurnId;
811 });
812
813 const getSourceProposedPlanReferenceForAcceptedTurnStart = Effect.fnUntraced(function* (
814 threadId: ThreadId,
815 eventTurnId: TurnId | undefined,
816 ) {
817 if (eventTurnId === undefined) {
818 return null;
819 }
820
821 const expectedTurnId = yield* getExpectedProviderTurnIdForThread(threadId);
822 if (!sameId(expectedTurnId, eventTurnId)) {
823 return null;
824 }
825
826 return yield* getSourceProposedPlanReferenceForPendingTurnStart(threadId);
827 });
828
829 const markSourceProposedPlanImplemented = Effect.fnUntraced(function* (
830 sourceThreadId: ThreadId,
831 sourcePlanId: OrchestrationProposedPlanId,
832 implementationThreadId: ThreadId,
833 implementedAt: string,
834 ) {
835 const readModel = yield* orchestrationEngine.getReadModel();
836 const sourceThread = readModel.threads.find((entry) => entry.id === sourceThreadId);
837 const sourcePlan = sourceThread?.proposedPlans.find((entry) => entry.id === sourcePlanId);
838 if (!sourceThread || !sourcePlan || sourcePlan.implementedAt !== null) {
839 return;
840 }
841
842 yield* orchestrationEngine.dispatch({
843 type: "thread.proposed-plan.upsert",
844 commandId: CommandId.makeUnsafe(
845 `provider:source-proposed-plan-implemented:${implementationThreadId}:${crypto.randomUUID()}`,
846 ),
847 threadId: sourceThread.id,
848 proposedPlan: {
849 ...sourcePlan,
850 implementedAt,
851 implementationThreadId,
852 updatedAt: implementedAt,
853 },
854 createdAt: implementedAt,
855 });
856 });
857
775 const processRuntimeEvent = (event: ProviderRuntimeEvent) =>858 const processRuntimeEvent = (event: ProviderRuntimeEvent) =>
776 Effect.gen(function* () {859 Effect.gen(function* () {
777 const readModel = yield* orchestrationEngine.getReadModel();860 const readModel = yield* orchestrationEngine.getReadModel();
...@@ -812,6 +895,10 @@ const make = Effect.gen(function* () {...@@ -812,6 +895,10 @@ const make = Effect.gen(function* () {
812 return true;895 return true;
813 }896 }
814 })();897 })();
898 const acceptedTurnStartedSourcePlan =
899 event.type === "turn.started" && shouldApplyThreadLifecycle
900 ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId)
901 : null;
815902
816 if (903 if (
817 event.type === "session.started" ||904 event.type === "session.started" ||
...@@ -854,6 +941,26 @@ const make = Effect.gen(function* () {...@@ -854,6 +941,26 @@ const make = Effect.gen(function* () {
854 : (thread.session?.lastError ?? null);941 : (thread.session?.lastError ?? null);
855942
856 if (shouldApplyThreadLifecycle) {943 if (shouldApplyThreadLifecycle) {
944 if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) {
945 yield* markSourceProposedPlanImplemented(
946 acceptedTurnStartedSourcePlan.sourceThreadId,
947 acceptedTurnStartedSourcePlan.sourcePlanId,
948 thread.id,
949 now,
950 ).pipe(
951 Effect.catchCause((cause) =>
952 Effect.logWarning(
953 "provider runtime ingestion failed to mark source proposed plan",
954 {
955 eventId: event.eventId,
956 eventType: event.type,
957 cause: Cause.pretty(cause),
958 },
959 ),
960 ),
961 );
962 }
963
857 yield* orchestrationEngine.dispatch({964 yield* orchestrationEngine.dispatch({
858 type: "thread.session.set",965 type: "thread.session.set",
859 commandId: providerCommandId(event, "thread-session-set"),966 commandId: providerCommandId(event, "thread-session-set"),
...@@ -1144,4 +1251,7 @@ const make = Effect.gen(function* () {...@@ -1144,4 +1251,7 @@ const make = Effect.gen(function* () {
1144 } satisfies ProviderRuntimeIngestionShape;1251 } satisfies ProviderRuntimeIngestionShape;
1145});1252});
11461253
1147export const ProviderRuntimeIngestionLive = Layer.effect(ProviderRuntimeIngestionService, make);1254export const ProviderRuntimeIngestionLive = Layer.effect(
1255 ProviderRuntimeIngestionService,
1256 make,
1257).pipe(Layer.provide(ProjectionTurnRepositoryLive));
apps/server/src/orchestration/decider.ts+28-7
...@@ -262,11 +262,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -262,11 +262,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
262 }262 }
263263
264 case "thread.turn.start": {264 case "thread.turn.start": {
265 yield* requireThread({265 const targetThread = yield* requireThread({
266 readModel,266 readModel,
267 command,267 command,
268 threadId: command.threadId,268 threadId: command.threadId,
269 });269 });
270 const sourceProposedPlan = command.sourceProposedPlan;
271 const sourceThread = sourceProposedPlan
272 ? yield* requireThread({
273 readModel,
274 command,
275 threadId: sourceProposedPlan.threadId,
276 })
277 : null;
278 const sourcePlan =
279 sourceProposedPlan && sourceThread
280 ? sourceThread.proposedPlans.find((entry) => entry.id === sourceProposedPlan.planId)
281 : null;
282 if (sourceProposedPlan && !sourcePlan) {
283 return yield* new OrchestrationCommandInvariantError({
284 commandType: command.type,
285 detail: `Proposed plan '${sourceProposedPlan.planId}' does not exist on thread '${sourceProposedPlan.threadId}'.`,
286 });
287 }
288 if (sourceThread && sourceThread.projectId !== targetThread.projectId) {
289 return yield* new OrchestrationCommandInvariantError({
290 commandType: command.type,
291 detail: `Proposed plan '${sourceProposedPlan?.planId}' belongs to thread '${sourceThread.id}' in a different project.`,
292 });
293 }
270 const userMessageEvent: Omit<OrchestrationEvent, "sequence"> = {294 const userMessageEvent: Omit<OrchestrationEvent, "sequence"> = {
271 ...withEventBase({295 ...withEventBase({
272 aggregateKind: "thread",296 aggregateKind: "thread",
...@@ -306,12 +330,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -306,12 +330,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
306 ? { providerOptions: command.providerOptions }330 ? { providerOptions: command.providerOptions }
307 : {}),331 : {}),
308 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,332 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,
309 runtimeMode:333 runtimeMode: targetThread.runtimeMode,
310 readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ??334 interactionMode: targetThread.interactionMode,
311 command.runtimeMode,335 ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}),
312 interactionMode:
313 readModel.threads.find((entry) => entry.id === command.threadId)?.interactionMode ??
314 command.interactionMode,
315 createdAt: command.createdAt,336 createdAt: command.createdAt,
316 },337 },
317 };338 };
apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts+8
...@@ -22,6 +22,8 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () {...@@ -22,6 +22,8 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () {
22 thread_id,22 thread_id,
23 turn_id,23 turn_id,
24 plan_markdown,24 plan_markdown,
25 implemented_at,
26 implementation_thread_id,
25 created_at,27 created_at,
26 updated_at28 updated_at
27 )29 )
...@@ -30,6 +32,8 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () {...@@ -30,6 +32,8 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () {
30 ${row.threadId},32 ${row.threadId},
31 ${row.turnId},33 ${row.turnId},
32 ${row.planMarkdown},34 ${row.planMarkdown},
35 ${row.implementedAt},
36 ${row.implementationThreadId},
33 ${row.createdAt},37 ${row.createdAt},
34 ${row.updatedAt}38 ${row.updatedAt}
35 )39 )
...@@ -38,6 +42,8 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () {...@@ -38,6 +42,8 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () {
38 thread_id = excluded.thread_id,42 thread_id = excluded.thread_id,
39 turn_id = excluded.turn_id,43 turn_id = excluded.turn_id,
40 plan_markdown = excluded.plan_markdown,44 plan_markdown = excluded.plan_markdown,
45 implemented_at = excluded.implemented_at,
46 implementation_thread_id = excluded.implementation_thread_id,
41 created_at = excluded.created_at,47 created_at = excluded.created_at,
42 updated_at = excluded.updated_at48 updated_at = excluded.updated_at
43 `,49 `,
...@@ -52,6 +58,8 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () {...@@ -52,6 +58,8 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () {
52 thread_id AS "threadId",58 thread_id AS "threadId",
53 turn_id AS "turnId",59 turn_id AS "turnId",
54 plan_markdown AS "planMarkdown",60 plan_markdown AS "planMarkdown",
61 implemented_at AS "implementedAt",
62 implementation_thread_id AS "implementationThreadId",
55 created_at AS "createdAt",63 created_at AS "createdAt",
56 updated_at AS "updatedAt"64 updated_at AS "updatedAt"
57 FROM projection_thread_proposed_plans65 FROM projection_thread_proposed_plans
apps/server/src/persistence/Layers/ProjectionTurns.ts+6
...@@ -106,6 +106,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () {...@@ -106,6 +106,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () {
106 thread_id,106 thread_id,
107 turn_id,107 turn_id,
108 pending_message_id,108 pending_message_id,
109 source_proposed_plan_thread_id,
110 source_proposed_plan_id,
109 assistant_message_id,111 assistant_message_id,
110 state,112 state,
111 requested_at,113 requested_at,
...@@ -120,6 +122,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () {...@@ -120,6 +122,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () {
120 ${row.threadId},122 ${row.threadId},
121 NULL,123 NULL,
122 ${row.messageId},124 ${row.messageId},
125 ${row.sourceProposedPlanThreadId},
126 ${row.sourceProposedPlanId},
123 NULL,127 NULL,
124 'pending',128 'pending',
125 ${row.requestedAt},129 ${row.requestedAt},
...@@ -141,6 +145,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () {...@@ -141,6 +145,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () {
141 SELECT145 SELECT
142 thread_id AS "threadId",146 thread_id AS "threadId",
143 pending_message_id AS "messageId",147 pending_message_id AS "messageId",
148 source_proposed_plan_thread_id AS "sourceProposedPlanThreadId",
149 source_proposed_plan_id AS "sourceProposedPlanId",
144 requested_at AS "requestedAt"150 requested_at AS "requestedAt"
145 FROM projection_turns151 FROM projection_turns
146 WHERE thread_id = ${threadId}152 WHERE thread_id = ${threadId}
apps/server/src/persistence/Migrations.ts+4
...@@ -25,6 +25,8 @@ import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts";...@@ -25,6 +25,8 @@ import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts";
25import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts";25import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts";
26import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts";26import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts";
27import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts";27import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts";
28import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts";
29import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts";
28import { Effect } from "effect";30import { Effect } from "effect";
2931
30/**32/**
...@@ -51,6 +53,8 @@ const loader = Migrator.fromRecord({...@@ -51,6 +53,8 @@ const loader = Migrator.fromRecord({
51 "11_OrchestrationThreadCreatedRuntimeMode": Migration0011,53 "11_OrchestrationThreadCreatedRuntimeMode": Migration0011,
52 "12_ProjectionThreadsInteractionMode": Migration0012,54 "12_ProjectionThreadsInteractionMode": Migration0012,
53 "13_ProjectionThreadProposedPlans": Migration0013,55 "13_ProjectionThreadProposedPlans": Migration0013,
56 "14_ProjectionThreadProposedPlanImplementation": Migration0014,
57 "15_ProjectionTurnsSourceProposedPlan": Migration0015,
54});58});
5559
56/**60/**
apps/server/src/persistence/Migrations/014_ProjectionThreadProposedPlanImplementation.ts created+16
...@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 ALTER TABLE projection_thread_proposed_plans
9 ADD COLUMN implemented_at TEXT
10 `;
11
12 yield* sql`
13 ALTER TABLE projection_thread_proposed_plans
14 ADD COLUMN implementation_thread_id TEXT
15 `;
16});
apps/server/src/persistence/Migrations/015_ProjectionTurnsSourceProposedPlan.ts created+16
...@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 ALTER TABLE projection_turns
9 ADD COLUMN source_proposed_plan_thread_id TEXT
10 `;
11
12 yield* sql`
13 ALTER TABLE projection_turns
14 ADD COLUMN source_proposed_plan_id TEXT
15 `;
16});
apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts+2
...@@ -15,6 +15,8 @@ export const ProjectionThreadProposedPlan = Schema.Struct({...@@ -15,6 +15,8 @@ export const ProjectionThreadProposedPlan = Schema.Struct({
15 threadId: ThreadId,15 threadId: ThreadId,
16 turnId: Schema.NullOr(TurnId),16 turnId: Schema.NullOr(TurnId),
17 planMarkdown: TrimmedNonEmptyString,17 planMarkdown: TrimmedNonEmptyString,
18 implementedAt: Schema.NullOr(IsoDateTime),
19 implementationThreadId: Schema.NullOr(ThreadId),
18 createdAt: IsoDateTime,20 createdAt: IsoDateTime,
19 updatedAt: IsoDateTime,21 updatedAt: IsoDateTime,
20});22});
apps/server/src/persistence/Services/ProjectionTurns.ts+3
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 IsoDateTime,11 IsoDateTime,
12 MessageId,12 MessageId,
13 NonNegativeInt,13 NonNegativeInt,
14 OrchestrationProposedPlanId,
14 OrchestrationCheckpointFile,15 OrchestrationCheckpointFile,
15 OrchestrationCheckpointStatus,16 OrchestrationCheckpointStatus,
16 ThreadId,17 ThreadId,
...@@ -65,6 +66,8 @@ export type ProjectionTurnById = typeof ProjectionTurnById.Type;...@@ -65,6 +66,8 @@ export type ProjectionTurnById = typeof ProjectionTurnById.Type;
65export const ProjectionPendingTurnStart = Schema.Struct({66export const ProjectionPendingTurnStart = Schema.Struct({
66 threadId: ThreadId,67 threadId: ThreadId,
67 messageId: MessageId,68 messageId: MessageId,
69 sourceProposedPlanThreadId: Schema.NullOr(ThreadId),
70 sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId),
68 requestedAt: IsoDateTime,71 requestedAt: IsoDateTime,
69});72});
70export type ProjectionPendingTurnStart = typeof ProjectionPendingTurnStart.Type;73export type ProjectionPendingTurnStart = typeof ProjectionPendingTurnStart.Type;
apps/web/src/components/ChatView.browser.tsx+2
...@@ -368,6 +368,8 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {...@@ -368,6 +368,8 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
368 id: "plan-browser-test",368 id: "plan-browser-test",
369 turnId: null,369 turnId: null,
370 planMarkdown,370 planMarkdown,
371 implementedAt: null,
372 implementationThreadId: null,
371 createdAt: isoAt(1_000),373 createdAt: isoAt(1_000),
372 updatedAt: isoAt(1_001),374 updatedAt: isoAt(1_001),
373 },375 },
apps/web/src/components/ChatView.tsx+11-1
...@@ -55,6 +55,7 @@ import {...@@ -55,6 +55,7 @@ import {
55 deriveActivePlanState,55 deriveActivePlanState,
56 findLatestProposedPlan,56 findLatestProposedPlan,
57 deriveWorkLogEntries,57 deriveWorkLogEntries,
58 hasActionableProposedPlan,
58 hasToolActivityForTurn,59 hasToolActivityForTurn,
59 isLatestTurnSettled,60 isLatestTurnSettled,
60 formatElapsed,61 formatElapsed,
...@@ -721,7 +722,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -721,7 +722,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
721 pendingUserInputs.length === 0 &&722 pendingUserInputs.length === 0 &&
722 interactionMode === "plan" &&723 interactionMode === "plan" &&
723 latestTurnSettled &&724 latestTurnSettled &&
724 activeProposedPlan !== null;725 hasActionableProposedPlan(activeProposedPlan);
725 const activePendingApproval = pendingApprovals[0] ?? null;726 const activePendingApproval = pendingApprovals[0] ?? null;
726 const isComposerApprovalState = activePendingApproval !== null;727 const isComposerApprovalState = activePendingApproval !== null;
727 const hasComposerHeader =728 const hasComposerHeader =
...@@ -2878,6 +2879,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2878,6 +2879,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
2878 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",2879 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
2879 runtimeMode,2880 runtimeMode,
2880 interactionMode: nextInteractionMode,2881 interactionMode: nextInteractionMode,
2882 ...(nextInteractionMode === "default" && activeProposedPlan
2883 ? {
2884 sourceProposedPlan: {
2885 threadId: activeThread.id,
2886 planId: activeProposedPlan.id,
2887 },
2888 }
2889 : {}),
2881 createdAt: messageCreatedAt,2890 createdAt: messageCreatedAt,
2882 });2891 });
2883 // Optimistically open the plan sidebar when implementing (not refining).2892 // Optimistically open the plan sidebar when implementing (not refining).
...@@ -2902,6 +2911,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2902,6 +2911,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2902 },2911 },
2903 [2912 [
2904 activeThread,2913 activeThread,
2914 activeProposedPlan,
2905 beginSendPhase,2915 beginSendPhase,
2906 forceStickToBottom,2916 forceStickToBottom,
2907 isConnecting,2917 isConnecting,
apps/web/src/components/Sidebar.logic.test.ts+31
...@@ -141,6 +141,8 @@ describe("resolveThreadStatusPill", () => {...@@ -141,6 +141,8 @@ describe("resolveThreadStatusPill", () => {
141 createdAt: "2026-03-09T10:00:00.000Z",141 createdAt: "2026-03-09T10:00:00.000Z",
142 updatedAt: "2026-03-09T10:05:00.000Z",142 updatedAt: "2026-03-09T10:05:00.000Z",
143 planMarkdown: "# Plan",143 planMarkdown: "# Plan",
144 implementedAt: null,
145 implementationThreadId: null,
144 },146 },
145 ],147 ],
146 session: {148 session: {
...@@ -155,6 +157,35 @@ describe("resolveThreadStatusPill", () => {...@@ -155,6 +157,35 @@ describe("resolveThreadStatusPill", () => {
155 ).toMatchObject({ label: "Plan Ready", pulse: false });157 ).toMatchObject({ label: "Plan Ready", pulse: false });
156 });158 });
157159
160 it("does not show plan ready after the proposed plan was implemented elsewhere", () => {
161 expect(
162 resolveThreadStatusPill({
163 thread: {
164 ...baseThread,
165 latestTurn: makeLatestTurn(),
166 proposedPlans: [
167 {
168 id: "plan-1" as never,
169 turnId: "turn-1" as never,
170 createdAt: "2026-03-09T10:00:00.000Z",
171 updatedAt: "2026-03-09T10:05:00.000Z",
172 planMarkdown: "# Plan",
173 implementedAt: "2026-03-09T10:06:00.000Z",
174 implementationThreadId: "thread-implement" as never,
175 },
176 ],
177 session: {
178 ...baseThread.session,
179 status: "ready",
180 orchestrationStatus: "ready",
181 },
182 },
183 hasPendingApprovals: false,
184 hasPendingUserInput: false,
185 }),
186 ).toMatchObject({ label: "Completed", pulse: false });
187 });
188
158 it("shows completed when there is an unseen completion and no active blocker", () => {189 it("shows completed when there is an unseen completion and no active blocker", () => {
159 expect(190 expect(
160 resolveThreadStatusPill({191 resolveThreadStatusPill({
apps/web/src/components/Sidebar.logic.ts+8-2
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import type { Thread } from "../types";1import type { Thread } from "../types";
2import { cn } from "../lib/utils";2import { cn } from "../lib/utils";
3import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";3import {
4 findLatestProposedPlan,
5 hasActionableProposedPlan,
6 isLatestTurnSettled,
7} from "../session-logic";
48
5export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";9export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
6export type SidebarNewThreadEnvMode = "local" | "worktree";10export type SidebarNewThreadEnvMode = "local" | "worktree";
...@@ -124,7 +128,9 @@ export function resolveThreadStatusPill(input: {...@@ -124,7 +128,9 @@ export function resolveThreadStatusPill(input: {
124 !hasPendingUserInput &&128 !hasPendingUserInput &&
125 thread.interactionMode === "plan" &&129 thread.interactionMode === "plan" &&
126 isLatestTurnSettled(thread.latestTurn, thread.session) &&130 isLatestTurnSettled(thread.latestTurn, thread.session) &&
127 findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null) !== null;131 hasActionableProposedPlan(
132 findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null),
133 );
128 if (hasPlanReadyPrompt) {134 if (hasPlanReadyPrompt) {
129 return {135 return {
130 label: "Plan Ready",136 label: "Plan Ready",
apps/web/src/session-logic.test.ts+54-1
...@@ -1,4 +1,10 @@...@@ -1,4 +1,10 @@
1import { EventId, MessageId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";1import {
2 EventId,
3 MessageId,
4 ThreadId,
5 TurnId,
6 type OrchestrationThreadActivity,
7} from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";8import { describe, expect, it } from "vitest";
39
4import {10import {
...@@ -10,6 +16,7 @@ import {...@@ -10,6 +16,7 @@ import {
10 deriveTimelineEntries,16 deriveTimelineEntries,
11 deriveWorkLogEntries,17 deriveWorkLogEntries,
12 findLatestProposedPlan,18 findLatestProposedPlan,
19 hasActionableProposedPlan,
13 hasToolActivityForTurn,20 hasToolActivityForTurn,
14 isLatestTurnSettled,21 isLatestTurnSettled,
15} from "./session-logic";22} from "./session-logic";
...@@ -269,6 +276,8 @@ describe("findLatestProposedPlan", () => {...@@ -269,6 +276,8 @@ describe("findLatestProposedPlan", () => {
269 id: "plan:thread-1:turn:turn-1",276 id: "plan:thread-1:turn:turn-1",
270 turnId: TurnId.makeUnsafe("turn-1"),277 turnId: TurnId.makeUnsafe("turn-1"),
271 planMarkdown: "# Older",278 planMarkdown: "# Older",
279 implementedAt: null,
280 implementationThreadId: null,
272 createdAt: "2026-02-23T00:00:01.000Z",281 createdAt: "2026-02-23T00:00:01.000Z",
273 updatedAt: "2026-02-23T00:00:01.000Z",282 updatedAt: "2026-02-23T00:00:01.000Z",
274 },283 },
...@@ -276,6 +285,8 @@ describe("findLatestProposedPlan", () => {...@@ -276,6 +285,8 @@ describe("findLatestProposedPlan", () => {
276 id: "plan:thread-1:turn:turn-1",285 id: "plan:thread-1:turn:turn-1",
277 turnId: TurnId.makeUnsafe("turn-1"),286 turnId: TurnId.makeUnsafe("turn-1"),
278 planMarkdown: "# Latest",287 planMarkdown: "# Latest",
288 implementedAt: null,
289 implementationThreadId: null,
279 createdAt: "2026-02-23T00:00:01.000Z",290 createdAt: "2026-02-23T00:00:01.000Z",
280 updatedAt: "2026-02-23T00:00:02.000Z",291 updatedAt: "2026-02-23T00:00:02.000Z",
281 },292 },
...@@ -283,6 +294,8 @@ describe("findLatestProposedPlan", () => {...@@ -283,6 +294,8 @@ describe("findLatestProposedPlan", () => {
283 id: "plan:thread-1:turn:turn-2",294 id: "plan:thread-1:turn:turn-2",
284 turnId: TurnId.makeUnsafe("turn-2"),295 turnId: TurnId.makeUnsafe("turn-2"),
285 planMarkdown: "# Different turn",296 planMarkdown: "# Different turn",
297 implementedAt: null,
298 implementationThreadId: null,
286 createdAt: "2026-02-23T00:00:03.000Z",299 createdAt: "2026-02-23T00:00:03.000Z",
287 updatedAt: "2026-02-23T00:00:03.000Z",300 updatedAt: "2026-02-23T00:00:03.000Z",
288 },301 },
...@@ -293,6 +306,8 @@ describe("findLatestProposedPlan", () => {...@@ -293,6 +306,8 @@ describe("findLatestProposedPlan", () => {
293 id: "plan:thread-1:turn:turn-1",306 id: "plan:thread-1:turn:turn-1",
294 turnId: "turn-1",307 turnId: "turn-1",
295 planMarkdown: "# Latest",308 planMarkdown: "# Latest",
309 implementedAt: null,
310 implementationThreadId: null,
296 createdAt: "2026-02-23T00:00:01.000Z",311 createdAt: "2026-02-23T00:00:01.000Z",
297 updatedAt: "2026-02-23T00:00:02.000Z",312 updatedAt: "2026-02-23T00:00:02.000Z",
298 });313 });
...@@ -305,6 +320,8 @@ describe("findLatestProposedPlan", () => {...@@ -305,6 +320,8 @@ describe("findLatestProposedPlan", () => {
305 id: "plan:thread-1:turn:turn-1",320 id: "plan:thread-1:turn:turn-1",
306 turnId: TurnId.makeUnsafe("turn-1"),321 turnId: TurnId.makeUnsafe("turn-1"),
307 planMarkdown: "# First",322 planMarkdown: "# First",
323 implementedAt: null,
324 implementationThreadId: null,
308 createdAt: "2026-02-23T00:00:01.000Z",325 createdAt: "2026-02-23T00:00:01.000Z",
309 updatedAt: "2026-02-23T00:00:01.000Z",326 updatedAt: "2026-02-23T00:00:01.000Z",
310 },327 },
...@@ -312,6 +329,8 @@ describe("findLatestProposedPlan", () => {...@@ -312,6 +329,8 @@ describe("findLatestProposedPlan", () => {
312 id: "plan:thread-1:turn:turn-2",329 id: "plan:thread-1:turn:turn-2",
313 turnId: TurnId.makeUnsafe("turn-2"),330 turnId: TurnId.makeUnsafe("turn-2"),
314 planMarkdown: "# Latest",331 planMarkdown: "# Latest",
332 implementedAt: null,
333 implementationThreadId: null,
315 createdAt: "2026-02-23T00:00:02.000Z",334 createdAt: "2026-02-23T00:00:02.000Z",
316 updatedAt: "2026-02-23T00:00:03.000Z",335 updatedAt: "2026-02-23T00:00:03.000Z",
317 },336 },
...@@ -323,6 +342,36 @@ describe("findLatestProposedPlan", () => {...@@ -323,6 +342,36 @@ describe("findLatestProposedPlan", () => {
323 });342 });
324});343});
325344
345describe("hasActionableProposedPlan", () => {
346 it("returns true for an unimplemented proposed plan", () => {
347 expect(
348 hasActionableProposedPlan({
349 id: "plan-1",
350 turnId: TurnId.makeUnsafe("turn-1"),
351 planMarkdown: "# Plan",
352 implementedAt: null,
353 implementationThreadId: null,
354 createdAt: "2026-02-23T00:00:00.000Z",
355 updatedAt: "2026-02-23T00:00:01.000Z",
356 }),
357 ).toBe(true);
358 });
359
360 it("returns false for a proposed plan already implemented elsewhere", () => {
361 expect(
362 hasActionableProposedPlan({
363 id: "plan-1",
364 turnId: TurnId.makeUnsafe("turn-1"),
365 planMarkdown: "# Plan",
366 implementedAt: "2026-02-23T00:00:02.000Z",
367 implementationThreadId: ThreadId.makeUnsafe("thread-implement"),
368 createdAt: "2026-02-23T00:00:00.000Z",
369 updatedAt: "2026-02-23T00:00:02.000Z",
370 }),
371 ).toBe(false);
372 });
373});
374
326describe("deriveWorkLogEntries", () => {375describe("deriveWorkLogEntries", () => {
327 it("omits tool started entries and keeps completed entries", () => {376 it("omits tool started entries and keeps completed entries", () => {
328 const activities: OrchestrationThreadActivity[] = [377 const activities: OrchestrationThreadActivity[] = [
...@@ -531,6 +580,8 @@ describe("deriveTimelineEntries", () => {...@@ -531,6 +580,8 @@ describe("deriveTimelineEntries", () => {
531 id: "plan:thread-1:turn:turn-1",580 id: "plan:thread-1:turn:turn-1",
532 turnId: TurnId.makeUnsafe("turn-1"),581 turnId: TurnId.makeUnsafe("turn-1"),
533 planMarkdown: "# Ship it",582 planMarkdown: "# Ship it",
583 implementedAt: null,
584 implementationThreadId: null,
534 createdAt: "2026-02-23T00:00:02.000Z",585 createdAt: "2026-02-23T00:00:02.000Z",
535 updatedAt: "2026-02-23T00:00:02.000Z",586 updatedAt: "2026-02-23T00:00:02.000Z",
536 },587 },
...@@ -550,6 +601,8 @@ describe("deriveTimelineEntries", () => {...@@ -550,6 +601,8 @@ describe("deriveTimelineEntries", () => {
550 kind: "proposed-plan",601 kind: "proposed-plan",
551 proposedPlan: {602 proposedPlan: {
552 planMarkdown: "# Ship it",603 planMarkdown: "# Ship it",
604 implementedAt: null,
605 implementationThreadId: null,
553 },606 },
554 });607 });
555 });608 });
apps/web/src/session-logic.ts+13
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 type ProviderKind,7 type ProviderKind,
8 type ToolLifecycleItemType,8 type ToolLifecycleItemType,
9 type UserInputQuestion,9 type UserInputQuestion,
10 type ThreadId,
10 type TurnId,11 type TurnId,
11} from "@t3tools/contracts";12} from "@t3tools/contracts";
1213
...@@ -72,6 +73,8 @@ export interface LatestProposedPlanState {...@@ -72,6 +73,8 @@ export interface LatestProposedPlanState {
72 updatedAt: string;73 updatedAt: string;
73 turnId: TurnId | null;74 turnId: TurnId | null;
74 planMarkdown: string;75 planMarkdown: string;
76 implementedAt: string | null;
77 implementationThreadId: ThreadId | null;
75}78}
7679
77export type TimelineEntry =80export type TimelineEntry =
...@@ -380,6 +383,8 @@ export function findLatestProposedPlan(...@@ -380,6 +383,8 @@ export function findLatestProposedPlan(
380 updatedAt: matchingTurnPlan.updatedAt,383 updatedAt: matchingTurnPlan.updatedAt,
381 turnId: matchingTurnPlan.turnId,384 turnId: matchingTurnPlan.turnId,
382 planMarkdown: matchingTurnPlan.planMarkdown,385 planMarkdown: matchingTurnPlan.planMarkdown,
386 implementedAt: matchingTurnPlan.implementedAt,
387 implementationThreadId: matchingTurnPlan.implementationThreadId,
383 };388 };
384 }389 }
385 }390 }
...@@ -400,9 +405,17 @@ export function findLatestProposedPlan(...@@ -400,9 +405,17 @@ export function findLatestProposedPlan(
400 updatedAt: latestPlan.updatedAt,405 updatedAt: latestPlan.updatedAt,
401 turnId: latestPlan.turnId,406 turnId: latestPlan.turnId,
402 planMarkdown: latestPlan.planMarkdown,407 planMarkdown: latestPlan.planMarkdown,
408 implementedAt: latestPlan.implementedAt,
409 implementationThreadId: latestPlan.implementationThreadId,
403 };410 };
404}411}
405412
413export function hasActionableProposedPlan(
414 proposedPlan: LatestProposedPlanState | Pick<ProposedPlan, "implementedAt"> | null,
415): boolean {
416 return proposedPlan !== null && proposedPlan.implementedAt === null;
417}
418
406export function deriveWorkLogEntries(419export function deriveWorkLogEntries(
407 activities: ReadonlyArray<OrchestrationThreadActivity>,420 activities: ReadonlyArray<OrchestrationThreadActivity>,
408 latestTurnId: TurnId | undefined,421 latestTurnId: TurnId | undefined,
apps/web/src/store.ts+2
...@@ -304,6 +304,8 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea...@@ -304,6 +304,8 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea
304 id: proposedPlan.id,304 id: proposedPlan.id,
305 turnId: proposedPlan.turnId,305 turnId: proposedPlan.turnId,
306 planMarkdown: proposedPlan.planMarkdown,306 planMarkdown: proposedPlan.planMarkdown,
307 implementedAt: proposedPlan.implementedAt,
308 implementationThreadId: proposedPlan.implementationThreadId,
307 createdAt: proposedPlan.createdAt,309 createdAt: proposedPlan.createdAt,
308 updatedAt: proposedPlan.updatedAt,310 updatedAt: proposedPlan.updatedAt,
309 })),311 })),
apps/web/src/types.ts+2
...@@ -53,6 +53,8 @@ export interface ProposedPlan {...@@ -53,6 +53,8 @@ export interface ProposedPlan {
53 id: OrchestrationProposedPlanId;53 id: OrchestrationProposedPlanId;
54 turnId: TurnId | null;54 turnId: TurnId | null;
55 planMarkdown: string;55 planMarkdown: string;
56 implementedAt: string | null;
57 implementationThreadId: ThreadId | null;
56 createdAt: string;58 createdAt: string;
57 updatedAt: string;59 updatedAt: string;
58}60}
packages/contracts/src/orchestration.test.ts+76
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 DEFAULT_PROVIDER_INTERACTION_MODE,6 DEFAULT_PROVIDER_INTERACTION_MODE,
7 DEFAULT_RUNTIME_MODE,7 DEFAULT_RUNTIME_MODE,
8 OrchestrationGetTurnDiffInput,8 OrchestrationGetTurnDiffInput,
9 OrchestrationProposedPlan,
9 OrchestrationSession,10 OrchestrationSession,
10 ProjectCreateCommand,11 ProjectCreateCommand,
11 ThreadTurnStartCommand,12 ThreadTurnStartCommand,
...@@ -21,6 +22,7 @@ const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartC...@@ -21,6 +22,7 @@ const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartC
21const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(22const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(
22 ThreadTurnStartRequestedPayload,23 ThreadTurnStartRequestedPayload,
23);24);
25const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan);
24const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession);26const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession);
25const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload);27const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload);
2628
...@@ -186,6 +188,31 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () =>...@@ -186,6 +188,31 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () =>
186 }),188 }),
187);189);
188190
191it.effect("accepts a source proposed plan reference in thread.turn.start", () =>
192 Effect.gen(function* () {
193 const parsed = yield* decodeThreadTurnStartCommand({
194 type: "thread.turn.start",
195 commandId: "cmd-turn-source-plan",
196 threadId: "thread-2",
197 message: {
198 messageId: "msg-source-plan",
199 role: "user",
200 text: "implement this",
201 attachments: [],
202 },
203 sourceProposedPlan: {
204 threadId: "thread-1",
205 planId: "plan-1",
206 },
207 createdAt: "2026-01-01T00:00:00.000Z",
208 });
209 assert.deepStrictEqual(parsed.sourceProposedPlan, {
210 threadId: "thread-1",
211 planId: "plan-1",
212 });
213 }),
214);
215
189it.effect(216it.effect(
190 "decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode",217 "decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode",
191 () =>218 () =>
...@@ -198,9 +225,28 @@ it.effect(...@@ -198,9 +225,28 @@ it.effect(
198 assert.strictEqual(parsed.provider, undefined);225 assert.strictEqual(parsed.provider, undefined);
199 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);226 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
200 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);227 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
228 assert.strictEqual(parsed.sourceProposedPlan, undefined);
201 }),229 }),
202);230);
203231
232it.effect("decodes thread.turn-start-requested source proposed plan metadata when present", () =>
233 Effect.gen(function* () {
234 const parsed = yield* decodeThreadTurnStartRequestedPayload({
235 threadId: "thread-2",
236 messageId: "msg-2",
237 sourceProposedPlan: {
238 threadId: "thread-1",
239 planId: "plan-1",
240 },
241 createdAt: "2026-01-01T00:00:00.000Z",
242 });
243 assert.deepStrictEqual(parsed.sourceProposedPlan, {
244 threadId: "thread-1",
245 planId: "plan-1",
246 });
247 }),
248);
249
204it.effect("decodes orchestration session runtime mode defaults", () =>250it.effect("decodes orchestration session runtime mode defaults", () =>
205 Effect.gen(function* () {251 Effect.gen(function* () {
206 const parsed = yield* decodeOrchestrationSession({252 const parsed = yield* decodeOrchestrationSession({
...@@ -216,3 +262,33 @@ it.effect("decodes orchestration session runtime mode defaults", () =>...@@ -216,3 +262,33 @@ it.effect("decodes orchestration session runtime mode defaults", () =>
216 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);262 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
217 }),263 }),
218);264);
265
266it.effect("defaults proposed plan implementation metadata for historical rows", () =>
267 Effect.gen(function* () {
268 const parsed = yield* decodeOrchestrationProposedPlan({
269 id: "plan-1",
270 turnId: "turn-1",
271 planMarkdown: "# Plan",
272 createdAt: "2026-01-01T00:00:00.000Z",
273 updatedAt: "2026-01-01T00:00:00.000Z",
274 });
275 assert.strictEqual(parsed.implementedAt, null);
276 assert.strictEqual(parsed.implementationThreadId, null);
277 }),
278);
279
280it.effect("preserves proposed plan implementation metadata when present", () =>
281 Effect.gen(function* () {
282 const parsed = yield* decodeOrchestrationProposedPlan({
283 id: "plan-2",
284 turnId: "turn-2",
285 planMarkdown: "# Plan",
286 implementedAt: "2026-01-02T00:00:00.000Z",
287 implementationThreadId: "thread-2",
288 createdAt: "2026-01-01T00:00:00.000Z",
289 updatedAt: "2026-01-02T00:00:00.000Z",
290 });
291 assert.strictEqual(parsed.implementedAt, "2026-01-02T00:00:00.000Z");
292 assert.strictEqual(parsed.implementationThreadId, "thread-2");
293 }),
294);
packages/contracts/src/orchestration.ts+10
...@@ -163,11 +163,18 @@ export const OrchestrationProposedPlan = Schema.Struct({...@@ -163,11 +163,18 @@ export const OrchestrationProposedPlan = Schema.Struct({
163 id: OrchestrationProposedPlanId,163 id: OrchestrationProposedPlanId,
164 turnId: Schema.NullOr(TurnId),164 turnId: Schema.NullOr(TurnId),
165 planMarkdown: TrimmedNonEmptyString,165 planMarkdown: TrimmedNonEmptyString,
166 implementedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)),
167 implementationThreadId: Schema.NullOr(ThreadId).pipe(Schema.withDecodingDefault(() => null)),
166 createdAt: IsoDateTime,168 createdAt: IsoDateTime,
167 updatedAt: IsoDateTime,169 updatedAt: IsoDateTime,
168});170});
169export type OrchestrationProposedPlan = typeof OrchestrationProposedPlan.Type;171export type OrchestrationProposedPlan = typeof OrchestrationProposedPlan.Type;
170172
173const SourceProposedPlanReference = Schema.Struct({
174 threadId: ThreadId,
175 planId: OrchestrationProposedPlanId,
176});
177
171export const OrchestrationSessionStatus = Schema.Literals([178export const OrchestrationSessionStatus = Schema.Literals([
172 "idle",179 "idle",
173 "starting",180 "starting",
...@@ -374,6 +381,7 @@ export const ThreadTurnStartCommand = Schema.Struct({...@@ -374,6 +381,7 @@ export const ThreadTurnStartCommand = Schema.Struct({
374 interactionMode: ProviderInteractionMode.pipe(381 interactionMode: ProviderInteractionMode.pipe(
375 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),382 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
376 ),383 ),
384 sourceProposedPlan: Schema.optional(SourceProposedPlanReference),
377 createdAt: IsoDateTime,385 createdAt: IsoDateTime,
378});386});
379387
...@@ -394,6 +402,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({...@@ -394,6 +402,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({
394 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),402 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
395 runtimeMode: RuntimeMode,403 runtimeMode: RuntimeMode,
396 interactionMode: ProviderInteractionMode,404 interactionMode: ProviderInteractionMode,
405 sourceProposedPlan: Schema.optional(SourceProposedPlanReference),
397 createdAt: IsoDateTime,406 createdAt: IsoDateTime,
398});407});
399408
...@@ -676,6 +685,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({...@@ -676,6 +685,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({
676 interactionMode: ProviderInteractionMode.pipe(685 interactionMode: ProviderInteractionMode.pipe(
677 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),686 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
678 ),687 ),
688 sourceProposedPlan: Schema.optional(SourceProposedPlanReference),
679 createdAt: IsoDateTime,689 createdAt: IsoDateTime,
680});690});
681691