| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | MessageId, | 4 | MessageId, |
| 5 | EventId, | 5 | EventId, |
| 6 | NodeId, | 6 | NodeId, |
| | 7 | type OrchestrationV2DomainEvent, |
| 7 | PlanId, | 8 | PlanId, |
| 8 | ProjectId, | 9 | ProjectId, |
| 9 | ProviderDriverKind, | 10 | ProviderDriverKind, |
| ... | @@ -23,6 +24,8 @@ import * as Layer from "effect/Layer"; | ... | @@ -23,6 +24,8 @@ import * as Layer from "effect/Layer"; |
| 23 | import * as SqlClient from "effect/sql/SqlClient"; | 24 | import * as SqlClient from "effect/sql/SqlClient"; |
| 24 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 25 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 25 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | 26 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| | 27 | import * as DelegatedTaskQuestionReactor from "./DelegatedTaskQuestionReactor.ts"; |
| | 28 | import * as EventSink from "./EventSink.ts"; |
| 26 | import * as Orchestrator from "./Orchestrator.ts"; | 29 | import * as Orchestrator from "./Orchestrator.ts"; |
| 27 | import * as ProjectionStore from "./ProjectionStore.ts"; | 30 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 28 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; | 31 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| ... | @@ -624,3 +627,336 @@ it.effect("keeps delegated child pull-request links independent of the parent", | ... | @@ -624,3 +627,336 @@ it.effect("keeps delegated child pull-request links independent of the parent", |
| 624 | assert.deepEqual(parentAfterChildLink.thread.pullRequests, parent.thread.pullRequests); | 627 | assert.deepEqual(parentAfterChildLink.thread.pullRequests, parent.thread.pullRequests); |
| 625 | }).pipe(Effect.provide(layerTest)), | 628 | }).pipe(Effect.provide(layerTest)), |
| 626 | ); | 629 | ); |
| | 630 | |
| | 631 | it.effect("wakes the parent once when its delegated task asks a question", () => |
| | 632 | Effect.gen(function* () { |
| | 633 | const orchestrator = yield* Orchestrator.OrchestratorV2; |
| | 634 | const projections = yield* ProjectionStore.ProjectionStoreV2; |
| | 635 | const reactor = yield* DelegatedTaskQuestionReactor.DelegatedTaskQuestionReactor; |
| | 636 | const parentThreadId = ThreadId.make("thread:question-parent"); |
| | 637 | yield* orchestrator.dispatch({ |
| | 638 | type: "thread.create", |
| | 639 | commandId: CommandId.make("create-question-parent"), |
| | 640 | threadId: parentThreadId, |
| | 641 | projectId: ProjectId.make("project:question"), |
| | 642 | title: "Parent", |
| | 643 | modelSelection, |
| | 644 | runtimeMode: "full-access", |
| | 645 | interactionMode: "default", |
| | 646 | branch: null, |
| | 647 | worktreePath: null, |
| | 648 | createdBy: "user", |
| | 649 | creationSource: "web", |
| | 650 | }); |
| | 651 | yield* orchestrator.dispatch({ |
| | 652 | type: "message.dispatch", |
| | 653 | commandId: CommandId.make("start-question-parent"), |
| | 654 | threadId: parentThreadId, |
| | 655 | messageId: MessageId.make("message:question-parent"), |
| | 656 | text: "Delegate the research", |
| | 657 | attachments: [], |
| | 658 | dispatchMode: { type: "start_immediately" }, |
| | 659 | createdBy: "user", |
| | 660 | creationSource: "web", |
| | 661 | }); |
| | 662 | const parentRun = (yield* projections.getThreadProjection(parentThreadId)).runs[0]!; |
| | 663 | yield* orchestrator.dispatch({ |
| | 664 | type: "delegated_task.request", |
| | 665 | commandId: CommandId.make("delegate-question-task"), |
| | 666 | parentThreadId, |
| | 667 | parentRunId: parentRun.id, |
| | 668 | parentNodeId: parentRun.rootNodeId!, |
| | 669 | task: "Research the sources", |
| | 670 | completionWake: "settled_only", |
| | 671 | modelSelection, |
| | 672 | runtimeMode: "full-access", |
| | 673 | interactionMode: "default", |
| | 674 | createdBy: "agent", |
| | 675 | creationSource: "mcp", |
| | 676 | }); |
| | 677 | const task = (yield* projections.getThreadProjection(parentThreadId)).subagents[0]!; |
| | 678 | const childRun = (yield* projections.getThreadRecords(task.childThreadId!, ["runs"])).runs[0]!; |
| | 679 | const now = yield* DateTime.now; |
| | 680 | const requestEvent = ( |
| | 681 | eventId: string, |
| | 682 | requestId: string, |
| | 683 | responseCapability: "live" | "message" = "message", |
| | 684 | kind: "user_input" | "command" = "user_input", |
| | 685 | run = childRun, |
| | 686 | ) => |
| | 687 | ({ |
| | 688 | id: EventId.make(eventId), |
| | 689 | type: "runtime-request.updated", |
| | 690 | threadId: task.childThreadId!, |
| | 691 | runId: run.id, |
| | 692 | occurredAt: now, |
| | 693 | payload: { |
| | 694 | id: RuntimeRequestId.make(requestId), |
| | 695 | nodeId: run.rootNodeId!, |
| | 696 | providerTurnId: null, |
| | 697 | nativeRequestRef: null, |
| | 698 | kind, |
| | 699 | status: "pending", |
| | 700 | responseCapability: |
| | 701 | responseCapability === "live" |
| | 702 | ? { type: "live", providerSessionId: ProviderSessionId.make("session:question") } |
| | 703 | : { type: "message" }, |
| | 704 | createdAt: now, |
| | 705 | resolvedAt: null, |
| | 706 | }, |
| | 707 | }) satisfies OrchestrationV2DomainEvent; |
| | 708 | const question = requestEvent("event:question", "request:question", "live"); |
| | 709 | const asyncQuestion = requestEvent("event:async-question", "request:async-question"); |
| | 710 | const answered = requestEvent("event:answered", "request:answered", "live"); |
| | 711 | const approval = requestEvent("event:approval", "request:approval", "message", "command"); |
| | 712 | const eventSink = yield* EventSink.EventSinkV2; |
| | 713 | yield* eventSink.write({ |
| | 714 | events: [question, asyncQuestion, answered, approval], |
| | 715 | }); |
| | 716 | |
| | 717 | yield* reactor.handle(question); |
| | 718 | assert.isFalse( |
| | 719 | (yield* projections.getThreadProjection(parentThreadId)).messages.some( |
| | 720 | (message) => message.notification !== undefined, |
| | 721 | ), |
| | 722 | ); |
| | 723 | yield* reactor.handle(asyncQuestion); |
| | 724 | assert.equal( |
| | 725 | (yield* projections.getThreadProjection(parentThreadId)).messages.filter( |
| | 726 | (message) => message.notification !== undefined, |
| | 727 | ).length, |
| | 728 | 1, |
| | 729 | ); |
| | 730 | yield* reactor.handle(asyncQuestion); |
| | 731 | yield* reactor.handle(answered); |
| | 732 | yield* eventSink.write({ |
| | 733 | events: [ |
| | 734 | { |
| | 735 | ...answered, |
| | 736 | id: EventId.make("event:answered-resolved"), |
| | 737 | payload: { ...answered.payload, status: "resolved", resolvedAt: now }, |
| | 738 | }, |
| | 739 | ], |
| | 740 | }); |
| | 741 | yield* reactor.handle(answered); |
| | 742 | yield* reactor.handle(approval); |
| | 743 | |
| | 744 | const wakes = (yield* projections.getThreadProjection(parentThreadId)).messages.filter( |
| | 745 | (message) => message.notification !== undefined, |
| | 746 | ); |
| | 747 | assert.include(wakes[0]?.text, "asked a question and is still working"); |
| | 748 | assert.deepEqual( |
| | 749 | wakes.map((message) => message.notification), |
| | 750 | [ |
| | 751 | { |
| | 752 | source: { |
| | 753 | kind: "delegated_task", |
| | 754 | taskIds: [task.id], |
| | 755 | childThreadId: task.childThreadId!, |
| | 756 | }, |
| | 757 | outcome: "updated", |
| | 758 | summary: 'Delegated task "Research the sources" has a question', |
| | 759 | }, |
| | 760 | ], |
| | 761 | ); |
| | 762 | |
| | 763 | const wakeRun = (yield* projections.getThreadRecords(parentThreadId, ["runs"])).runs.find( |
| | 764 | (run) => run.userMessageId === wakes[0]!.id, |
| | 765 | )!; |
| | 766 | assert.equal(wakeRun.status, "queued"); |
| | 767 | const resolved = { |
| | 768 | ...asyncQuestion, |
| | 769 | id: EventId.make("event:question-resolved"), |
| | 770 | payload: { ...asyncQuestion.payload, status: "resolved" as const, resolvedAt: now }, |
| | 771 | }; |
| | 772 | yield* eventSink.write({ events: [resolved] }); |
| | 773 | yield* reactor.handle(resolved); |
| | 774 | assert.equal( |
| | 775 | (yield* projections.getThreadRecords(parentThreadId, ["runs"])).runs.find( |
| | 776 | (run) => run.id === wakeRun.id, |
| | 777 | )?.status, |
| | 778 | "cancelled", |
| | 779 | ); |
| | 780 | const parentCompleted = { |
| | 781 | id: EventId.make("event:question-parent-completed"), |
| | 782 | type: "run.updated", |
| | 783 | threadId: parentThreadId, |
| | 784 | runId: parentRun.id, |
| | 785 | occurredAt: now, |
| | 786 | payload: { ...parentRun, status: "completed", completedAt: now }, |
| | 787 | } satisfies OrchestrationV2DomainEvent; |
| | 788 | for (const status of [ |
| | 789 | "preparing", |
| | 790 | "starting", |
| | 791 | "running", |
| | 792 | "queued", |
| | 793 | "cancelled", |
| | 794 | "rolled_back", |
| | 795 | "interrupted", |
| | 796 | ] as const) { |
| | 797 | yield* reactor.handle({ ...parentCompleted, payload: { ...parentRun, status } }); |
| | 798 | } |
| | 799 | assert.equal( |
| | 800 | (yield* projections.getThreadRecords(parentThreadId, ["messages"])).messages.length, |
| | 801 | 2, |
| | 802 | ); |
| | 803 | yield* eventSink.write({ events: [parentCompleted] }); |
| | 804 | for (const state of ["disposed", "pending"] as const) { |
| | 805 | yield* eventSink.write({ |
| | 806 | events: [ |
| | 807 | { |
| | 808 | id: EventId.make(`event:question-task-${state}`), |
| | 809 | type: "subagent.updated", |
| | 810 | threadId: parentThreadId, |
| | 811 | occurredAt: now, |
| | 812 | payload: { ...task, completionDelivery: { state, observedByRunId: null } }, |
| | 813 | }, |
| | 814 | ], |
| | 815 | }); |
| | 816 | yield* reactor.handle(parentCompleted); |
| | 817 | if (state === "disposed") { |
| | 818 | assert.equal( |
| | 819 | (yield* projections.getThreadRecords(parentThreadId, ["messages"])).messages.length, |
| | 820 | 2, |
| | 821 | ); |
| | 822 | } |
| | 823 | } |
| | 824 | yield* reactor.handle(parentCompleted); |
| | 825 | const caughtUp = (yield* projections.getThreadProjection(parentThreadId)).messages.filter( |
| | 826 | (message) => message.notification !== undefined, |
| | 827 | ); |
| | 828 | assert.equal(caughtUp.length, 2); |
| | 829 | assert.equal( |
| | 830 | caughtUp.filter((message) => message.text.includes(`requestId ${question.payload.id}.`)) |
| | 831 | .length, |
| | 832 | 1, |
| | 833 | ); |
| | 834 | assert.include(caughtUp[1]?.text, "is blocked on a question"); |
| | 835 | assert.isFalse( |
| | 836 | caughtUp.some((message) => message.text.includes(`requestId ${answered.payload.id}.`)), |
| | 837 | ); |
| | 838 | yield* reactor.handle(question); |
| | 839 | assert.equal( |
| | 840 | (yield* projections.getThreadProjection(parentThreadId)).messages.filter( |
| | 841 | (message) => message.notification !== undefined, |
| | 842 | ).length, |
| | 843 | 2, |
| | 844 | ); |
| | 845 | const terminalQuestion = requestEvent( |
| | 846 | "event:terminal-question", |
| | 847 | "request:terminal-question", |
| | 848 | "live", |
| | 849 | ); |
| | 850 | yield* eventSink.write({ events: [terminalQuestion] }); |
| | 851 | yield* reactor.handle(terminalQuestion); |
| | 852 | assert.equal( |
| | 853 | (yield* projections.getThreadProjection(parentThreadId)).messages.filter( |
| | 854 | (message) => message.notification !== undefined, |
| | 855 | ).length, |
| | 856 | 3, |
| | 857 | ); |
| | 858 | yield* eventSink.write({ |
| | 859 | events: [ |
| | 860 | { |
| | 861 | id: EventId.make("event:question-parent-running-again"), |
| | 862 | type: "run.updated", |
| | 863 | threadId: parentThreadId, |
| | 864 | runId: parentRun.id, |
| | 865 | occurredAt: now, |
| | 866 | payload: { ...parentRun, status: "running", completedAt: null }, |
| | 867 | }, |
| | 868 | ], |
| | 869 | }); |
| | 870 | yield* orchestrator.dispatch({ |
| | 871 | type: "delegated_task.wake-policy", |
| | 872 | commandId: CommandId.make("terminal-question-wait-ended"), |
| | 873 | parentThreadId, |
| | 874 | taskId: task.id, |
| | 875 | completionWake: "always", |
| | 876 | }); |
| | 877 | const upgradedQuestion = requestEvent( |
| | 878 | "event:upgraded-question", |
| | 879 | "request:upgraded-question", |
| | 880 | "live", |
| | 881 | ); |
| | 882 | yield* eventSink.write({ events: [upgradedQuestion] }); |
| | 883 | yield* reactor.handle(upgradedQuestion); |
| | 884 | assert.equal( |
| | 885 | (yield* projections.getThreadProjection(parentThreadId)).messages.filter( |
| | 886 | (message) => message.notification !== undefined, |
| | 887 | ).length, |
| | 888 | 4, |
| | 889 | ); |
| | 890 | const waitDelivered = requestEvent("event:wait-delivered", "request:wait-delivered", "live"); |
| | 891 | const waitDeliveredParentCompleted = { |
| | 892 | ...parentCompleted, |
| | 893 | id: EventId.make("event:wait-delivered-parent-completed"), |
| | 894 | }; |
| | 895 | yield* eventSink.write({ events: [waitDelivered, waitDeliveredParentCompleted] }); |
| | 896 | yield* reactor.handle(waitDeliveredParentCompleted); |
| | 897 | assert.isFalse( |
| | 898 | (yield* projections.getThreadProjection(parentThreadId)).messages.some((message) => |
| | 899 | message.text.includes(`requestId ${waitDelivered.payload.id}.`), |
| | 900 | ), |
| | 901 | ); |
| | 902 | yield* eventSink.write({ |
| | 903 | commandId: CommandId.make("command:runtime-reconcile:question-followup"), |
| | 904 | events: [ |
| | 905 | { |
| | 906 | id: EventId.make("event:question-child-completed"), |
| | 907 | type: "run.updated", |
| | 908 | threadId: task.childThreadId!, |
| | 909 | occurredAt: now, |
| | 910 | payload: { ...childRun, status: "completed", completedAt: now }, |
| | 911 | }, |
| | 912 | { |
| | 913 | id: EventId.make("event:question-task-completed"), |
| | 914 | type: "subagent.updated", |
| | 915 | threadId: parentThreadId, |
| | 916 | occurredAt: now, |
| | 917 | payload: { |
| | 918 | ...task, |
| | 919 | status: "completed", |
| | 920 | result: "Sources researched.", |
| | 921 | completedAt: now, |
| | 922 | }, |
| | 923 | }, |
| | 924 | ], |
| | 925 | }); |
| | 926 | yield* orchestrator.dispatch({ |
| | 927 | type: "message.dispatch", |
| | 928 | commandId: CommandId.make("question-followup"), |
| | 929 | threadId: task.childThreadId!, |
| | 930 | messageId: MessageId.make("message:question-followup"), |
| | 931 | text: "Check the sources again", |
| | 932 | attachments: [], |
| | 933 | dispatchMode: { type: "start_immediately" }, |
| | 934 | createdBy: "user", |
| | 935 | creationSource: "web", |
| | 936 | }); |
| | 937 | const followup = (yield* projections.getThreadRecords(task.childThreadId!, ["runs"])).runs.find( |
| | 938 | (run) => run.userMessageId === "message:question-followup", |
| | 939 | )!; |
| | 940 | assert.isDefined(followup.delegatedTaskId); |
| | 941 | assert.notEqual(followup.delegatedTaskId, task.id); |
| | 942 | const followupQuestion = requestEvent( |
| | 943 | "event:followup-question", |
| | 944 | "request:followup-question", |
| | 945 | "live", |
| | 946 | "user_input", |
| | 947 | followup, |
| | 948 | ); |
| | 949 | yield* eventSink.write({ events: [followupQuestion] }); |
| | 950 | yield* reactor.handle(followupQuestion); |
| | 951 | const followupWake = (yield* projections.getThreadRecords(parentThreadId, [ |
| | 952 | "messages", |
| | 953 | ])).messages.find((message) => |
| | 954 | message.text.includes(`requestId ${followupQuestion.payload.id}`), |
| | 955 | )!; |
| | 956 | assert.deepEqual(followupWake.notification?.source, { |
| | 957 | kind: "delegated_task", |
| | 958 | taskIds: [followup.delegatedTaskId], |
| | 959 | childThreadId: task.childThreadId!, |
| | 960 | }); |
| | 961 | }).pipe(Effect.provide(DelegatedTaskQuestionReactor.layer.pipe(Layer.provideMerge(layerTest)))), |
| | 962 | ); |