diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 516d8b2a2..e2e82f89b 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -5,6 +5,7 @@ import { MessageId, ProjectId, ThreadId, + TurnId, } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { Effect } from "effect"; @@ -358,4 +359,136 @@ describe("decider project scripts", () => { }, }); }); + + it("rejects thread.turn.start when the source proposed plan is already implemented", async () => { + const now = new Date().toISOString(); + const initial = createEmptyReadModel(now); + const withProject = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create-plan-used"), + aggregateKind: "project", + aggregateId: asProjectId("project-1"), + type: "project.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-project-create-plan-used"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-project-create-plan-used"), + metadata: {}, + payload: { + projectId: asProjectId("project-1"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + const withSourceThread = await Effect.runPromise( + projectEvent(withProject, { + sequence: 2, + eventId: asEventId("evt-thread-create-plan-used-source"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-plan"), + type: "thread.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-thread-create-plan-used-source"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-thread-create-plan-used-source"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-plan"), + projectId: asProjectId("project-1"), + title: "Plan Thread", + model: "gpt-5-codex", + interactionMode: "plan", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + const withTargetThread = await Effect.runPromise( + projectEvent(withSourceThread, { + sequence: 3, + eventId: asEventId("evt-thread-create-plan-used-target"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-implement"), + type: "thread.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-thread-create-plan-used-target"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-thread-create-plan-used-target"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-implement"), + projectId: asProjectId("project-1"), + title: "Implementation Thread", + model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + const readModel = await Effect.runPromise( + projectEvent(withTargetThread, { + sequence: 4, + eventId: asEventId("evt-plan-upsert-plan-used"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-plan"), + type: "thread.proposed-plan-upserted", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-plan-upsert-plan-used"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-plan-upsert-plan-used"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-plan"), + proposedPlan: { + id: "plan-used", + turnId: TurnId.makeUnsafe("turn-plan"), + planMarkdown: "# Used plan", + implementedAt: now, + implementationThreadId: ThreadId.makeUnsafe("thread-elsewhere"), + createdAt: now, + updatedAt: now, + }, + }, + }), + ); + + await expect( + Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-plan-used"), + threadId: ThreadId.makeUnsafe("thread-implement"), + message: { + messageId: asMessageId("message-plan-used"), + role: "user", + text: "PLEASE IMPLEMENT THIS PLAN:\n# Used plan", + attachments: [], + }, + sourceProposedPlan: { + threadId: ThreadId.makeUnsafe("thread-plan"), + planId: "plan-used", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }, + readModel, + }), + ), + ).rejects.toThrow("Proposed plan 'plan-used' has already been implemented."); + }); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 6ea4c5175..b4eb31406 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -285,6 +285,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" detail: `Proposed plan '${sourceProposedPlan.planId}' does not exist on thread '${sourceProposedPlan.threadId}'.`, }); } + if (sourcePlan && sourcePlan.implementedAt !== null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Proposed plan '${sourceProposedPlan?.planId}' has already been implemented.`, + }); + } if (sourceThread && sourceThread.projectId !== targetThread.projectId) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8748272de..8d4c78e89 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2828,6 +2828,10 @@ export default function ChatView({ threadId }: ChatViewProps) { assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", + sourceProposedPlan: { + threadId: activeThread.id, + planId: activeProposedPlan.id, + }, createdAt, }); })