| ... | @@ -89,6 +89,7 @@ const runtimeMock = { | ... | @@ -89,6 +89,7 @@ const runtimeMock = { |
| 89 | subscribedEvents: [] as Array<unknown | Promise<unknown>>, | 89 | subscribedEvents: [] as Array<unknown | Promise<unknown>>, |
| 90 | eventSubscribeObserved: null as (() => void) | null, | 90 | eventSubscribeObserved: null as (() => void) | null, |
| 91 | permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>, | 91 | permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>, |
| | 92 | permissionReplyImplementation: null as (() => Promise<void>) | null, |
| 92 | questionReplyCalls: [] as Array<{ | 93 | questionReplyCalls: [] as Array<{ |
| 93 | requestID: string; | 94 | requestID: string; |
| 94 | answers: ReadonlyArray<ReadonlyArray<string>>; | 95 | answers: ReadonlyArray<ReadonlyArray<string>>; |
| ... | @@ -139,6 +140,7 @@ const runtimeMock = { | ... | @@ -139,6 +140,7 @@ const runtimeMock = { |
| 139 | this.state.subscribedEvents = []; | 140 | this.state.subscribedEvents = []; |
| 140 | this.state.eventSubscribeObserved = null; | 141 | this.state.eventSubscribeObserved = null; |
| 141 | this.state.permissionReplyCalls.length = 0; | 142 | this.state.permissionReplyCalls.length = 0; |
| | 143 | this.state.permissionReplyImplementation = null; |
| 142 | this.state.questionReplyCalls.length = 0; | 144 | this.state.questionReplyCalls.length = 0; |
| 143 | this.state.sessionStatus = "idle"; | 145 | this.state.sessionStatus = "idle"; |
| 144 | this.state.sessionStatusFailures = 0; | 146 | this.state.sessionStatusFailures = 0; |
| ... | @@ -377,6 +379,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -377,6 +379,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 377 | }, | 379 | }, |
| 378 | reply: async ({ requestID, reply }: { requestID: string; reply: string }) => { | 380 | reply: async ({ requestID, reply }: { requestID: string; reply: string }) => { |
| 379 | runtimeMock.state.permissionReplyCalls.push({ requestID, reply }); | 381 | runtimeMock.state.permissionReplyCalls.push({ requestID, reply }); |
| | 382 | if (runtimeMock.state.permissionReplyImplementation) { |
| | 383 | await runtimeMock.state.permissionReplyImplementation(); |
| | 384 | } |
| 380 | }, | 385 | }, |
| 381 | }, | 386 | }, |
| 382 | question: { | 387 | question: { |
| ... | @@ -2623,6 +2628,208 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | ... | @@ -2623,6 +2628,208 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 2623 | }), | 2628 | }), |
| 2624 | ); | 2629 | ); |
| 2625 | | 2630 | |
| | 2631 | it.effect.each([ |
| | 2632 | { |
| | 2633 | name: "a doom-loop ask on the parent session", |
| | 2634 | requestId: "per_doom_loop", |
| | 2635 | sessionID: "http://127.0.0.1:9999/session", |
| | 2636 | permission: "doom_loop", |
| | 2637 | patterns: ["bash"], |
| | 2638 | always: [] as string[], |
| | 2639 | }, |
| | 2640 | { |
| | 2641 | name: "a child-session ask", |
| | 2642 | requestId: "per_child_full", |
| | 2643 | sessionID: "ses_child_full", |
| | 2644 | permission: "read", |
| | 2645 | patterns: ["/repo/settings.env"], |
| | 2646 | always: ["/repo/settings.env"], |
| | 2647 | }, |
| | 2648 | ])( |
| | 2649 | "auto-approves $name in full access", |
| | 2650 | ({ requestId, sessionID, permission, patterns, always }) => |
| | 2651 | Effect.gen(function* () { |
| | 2652 | const adapter = yield* OpenCodeAdapter; |
| | 2653 | const threadId = asThreadId(`thread-full-access-${requestId}`); |
| | 2654 | runtimeMock.state.subscribedEvents = [ |
| | 2655 | { |
| | 2656 | id: "evt-child-created", |
| | 2657 | type: "session.created", |
| | 2658 | properties: { |
| | 2659 | sessionID: "ses_child_full", |
| | 2660 | info: { |
| | 2661 | id: "ses_child_full", |
| | 2662 | parentID: "http://127.0.0.1:9999/session", |
| | 2663 | title: "Child session", |
| | 2664 | }, |
| | 2665 | }, |
| | 2666 | }, |
| | 2667 | { |
| | 2668 | id: "evt-permission", |
| | 2669 | type: "permission.asked", |
| | 2670 | properties: { id: requestId, sessionID, permission, patterns, metadata: {}, always }, |
| | 2671 | }, |
| | 2672 | { |
| | 2673 | id: "evt-permission-replied", |
| | 2674 | type: "permission.replied", |
| | 2675 | properties: { sessionID, requestID: requestId, reply: "once" }, |
| | 2676 | }, |
| | 2677 | // The suppressed ask emits nothing, so an empty question serves as a |
| | 2678 | // sentinel that closes the collected stream once the pump is past it. |
| | 2679 | { |
| | 2680 | id: "evt-sentinel-question", |
| | 2681 | type: "question.asked", |
| | 2682 | properties: { |
| | 2683 | id: "que_sentinel", |
| | 2684 | sessionID: "http://127.0.0.1:9999/session", |
| | 2685 | questions: [], |
| | 2686 | }, |
| | 2687 | }, |
| | 2688 | ]; |
| | 2689 | |
| | 2690 | const eventsFiber = yield* adapter.streamEvents.pipe( |
| | 2691 | Stream.filter((event) => event.threadId === threadId), |
| | 2692 | Stream.takeUntil((event) => event.type === "user-input.requested"), |
| | 2693 | Stream.runCollect, |
| | 2694 | Effect.forkChild, |
| | 2695 | ); |
| | 2696 | yield* adapter.startSession({ |
| | 2697 | provider: ProviderDriverKind.make("opencode"), |
| | 2698 | threadId, |
| | 2699 | runtimeMode: "full-access", |
| | 2700 | }); |
| | 2701 | const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); |
| | 2702 | |
| | 2703 | NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ |
| | 2704 | { requestID: requestId, reply: "once" }, |
| | 2705 | ]); |
| | 2706 | NodeAssert.equal( |
| | 2707 | events.some((event) => event.type === "request.opened"), |
| | 2708 | false, |
| | 2709 | ); |
| | 2710 | NodeAssert.equal( |
| | 2711 | events.some((event) => event.type === "request.resolved"), |
| | 2712 | false, |
| | 2713 | ); |
| | 2714 | |
| | 2715 | yield* adapter.stopSession(threadId); |
| | 2716 | }), |
| | 2717 | ); |
| | 2718 | |
| | 2719 | it.effect("surfaces the approval when the full-access auto-reply fails", () => |
| | 2720 | Effect.gen(function* () { |
| | 2721 | const adapter = yield* OpenCodeAdapter; |
| | 2722 | const threadId = asThreadId("thread-full-access-reply-failed"); |
| | 2723 | runtimeMock.state.permissionReplyImplementation = async () => { |
| | 2724 | throw new Error("reply failed"); |
| | 2725 | }; |
| | 2726 | runtimeMock.state.subscribedEvents = [ |
| | 2727 | { |
| | 2728 | id: "evt-doom-loop", |
| | 2729 | type: "permission.asked", |
| | 2730 | properties: { |
| | 2731 | id: "per_doom_loop_failed", |
| | 2732 | sessionID: "http://127.0.0.1:9999/session", |
| | 2733 | permission: "doom_loop", |
| | 2734 | patterns: ["bash"], |
| | 2735 | metadata: {}, |
| | 2736 | always: [], |
| | 2737 | }, |
| | 2738 | }, |
| | 2739 | ]; |
| | 2740 | |
| | 2741 | const openedFiber = yield* adapter.streamEvents.pipe( |
| | 2742 | Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), |
| | 2743 | Stream.take(1), |
| | 2744 | Stream.runHead, |
| | 2745 | Effect.forkChild, |
| | 2746 | ); |
| | 2747 | yield* adapter.startSession({ |
| | 2748 | provider: ProviderDriverKind.make("opencode"), |
| | 2749 | threadId, |
| | 2750 | runtimeMode: "full-access", |
| | 2751 | }); |
| | 2752 | const opened = Option.getOrUndefined( |
| | 2753 | yield* Fiber.join(openedFiber).pipe(Effect.timeout("1 second")), |
| | 2754 | ); |
| | 2755 | NodeAssert.equal(opened?.requestId, "per_doom_loop_failed"); |
| | 2756 | // Exactly one auto-reply attempt: the fallback surfaces the dialog |
| | 2757 | // instead of retrying the reply. |
| | 2758 | NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ |
| | 2759 | { requestID: "per_doom_loop_failed", reply: "once" }, |
| | 2760 | ]); |
| | 2761 | |
| | 2762 | yield* adapter.stopSession(threadId); |
| | 2763 | }), |
| | 2764 | ); |
| | 2765 | |
| | 2766 | it.effect("does not reopen a failed full-access auto-reply after its terminal reply", () => |
| | 2767 | Effect.gen(function* () { |
| | 2768 | const adapter = yield* OpenCodeAdapter; |
| | 2769 | const threadId = asThreadId("thread-full-access-reply-failed-after-terminal"); |
| | 2770 | const childId = "ses_full_access_terminal_child"; |
| | 2771 | const request = permissionRequest("per_failed_after_terminal", childId); |
| | 2772 | const ancestryAttempted = promiseWithResolvers<void>(); |
| | 2773 | const releaseReply = promiseWithResolvers<void>(); |
| | 2774 | // The ask arrives from a child whose ancestry lookup is failing, so it |
| | 2775 | // is handled on a retry fiber. The terminal reply lands while that |
| | 2776 | // fiber's auto-reply is still in flight; the reply then fails. The |
| | 2777 | // request must neither reopen nor emit a stray resolution. |
| | 2778 | runtimeMock.state.sessionParentById.set(childId, "http://127.0.0.1:9999/session"); |
| | 2779 | runtimeMock.state.transientErrorSessionIds.add(childId); |
| | 2780 | runtimeMock.state.sessionGetObserved = (sessionID) => { |
| | 2781 | if (sessionID === childId) { |
| | 2782 | ancestryAttempted.resolve(undefined); |
| | 2783 | } |
| | 2784 | }; |
| | 2785 | runtimeMock.state.permissionReplyImplementation = async () => { |
| | 2786 | await releaseReply.promise; |
| | 2787 | throw new Error("reply failed"); |
| | 2788 | }; |
| | 2789 | const terminalEvent = promiseWithResolvers<unknown>(); |
| | 2790 | runtimeMock.state.subscribedEvents = [ |
| | 2791 | { id: "evt-ask", type: "permission.asked", properties: request }, |
| | 2792 | terminalEvent.promise, |
| | 2793 | ]; |
| | 2794 | |
| | 2795 | const requestEventsFiber = yield* adapter.streamEvents.pipe( |
| | 2796 | Stream.filter( |
| | 2797 | (event) => |
| | 2798 | event.threadId === threadId && |
| | 2799 | (event.type === "request.opened" || event.type === "request.resolved"), |
| | 2800 | ), |
| | 2801 | Stream.runHead, |
| | 2802 | Effect.forkChild, |
| | 2803 | ); |
| | 2804 | yield* adapter.startSession({ |
| | 2805 | provider: ProviderDriverKind.make("opencode"), |
| | 2806 | threadId, |
| | 2807 | runtimeMode: "full-access", |
| | 2808 | }); |
| | 2809 | yield* Effect.promise(() => ancestryAttempted.promise); |
| | 2810 | runtimeMock.state.transientErrorSessionIds.delete(childId); |
| | 2811 | yield* advanceTestClock(250); |
| | 2812 | NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ |
| | 2813 | { requestID: request.id, reply: "once" }, |
| | 2814 | ]); |
| | 2815 | |
| | 2816 | // Drain the microtask queue so the pump has consumed the terminal reply |
| | 2817 | // before the in-flight auto-reply is allowed to fail. |
| | 2818 | terminalEvent.resolve({ |
| | 2819 | id: "evt-reply", |
| | 2820 | type: "permission.replied", |
| | 2821 | properties: { sessionID: childId, requestID: request.id, reply: "once" }, |
| | 2822 | }); |
| | 2823 | yield* Effect.promise(() => new Promise<void>((resolve) => setImmediate(resolve))); |
| | 2824 | releaseReply.resolve(undefined); |
| | 2825 | yield* advanceTestClock(250); |
| | 2826 | |
| | 2827 | NodeAssert.equal(requestEventsFiber.pollUnsafe(), undefined); |
| | 2828 | yield* Fiber.interrupt(requestEventsFiber); |
| | 2829 | yield* adapter.stopSession(threadId); |
| | 2830 | }), |
| | 2831 | ); |
| | 2832 | |
| 2626 | it.effect("routes child-session questions and replies through the parent thread", () => | 2833 | it.effect("routes child-session questions and replies through the parent thread", () => |
| 2627 | Effect.gen(function* () { | 2834 | Effect.gen(function* () { |
| 2628 | const adapter = yield* OpenCodeAdapter; | 2835 | const adapter = yield* OpenCodeAdapter; |