| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | MessageId, |
| 5 | 5 | EventId, |
| 6 | 6 | NodeId, |
| 7 | type OrchestrationV2DomainEvent, |
| 7 | 8 | PlanId, |
| 8 | 9 | ProjectId, |
| 9 | 10 | ProviderDriverKind, |
| ... | ... | @@ -23,6 +24,8 @@ import * as Layer from "effect/Layer"; |
| 23 | 24 | import * as SqlClient from "effect/sql/SqlClient"; |
| 24 | 25 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 25 | 26 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 27 | import * as DelegatedTaskQuestionReactor from "./DelegatedTaskQuestionReactor.ts"; |
| 28 | import * as EventSink from "./EventSink.ts"; |
| 26 | 29 | import * as Orchestrator from "./Orchestrator.ts"; |
| 27 | 30 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 28 | 31 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| ... | ... | @@ -624,3 +627,336 @@ it.effect("keeps delegated child pull-request links independent of the parent", |
| 624 | 627 | assert.deepEqual(parentAfterChildLink.thread.pullRequests, parent.thread.pullRequests); |
| 625 | 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 | ); |