| ... | @@ -715,6 +715,28 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { | ... | @@ -715,6 +715,28 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { |
| 715 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | 715 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, |
| 716 | } as OrchestrationV2AppThread, | 716 | } as OrchestrationV2AppThread, |
| 717 | }); | 717 | }); |
| | 718 | /** |
| | 719 | * The continuation turn the orchestrator starts for an offered wake: its |
| | 720 | * message is the one the offer named, as `ProviderContinuationService` dispatches it. |
| | 721 | */ |
| | 722 | const continuationTurn = ( |
| | 723 | thread: OrchestrationV2ProviderThread, |
| | 724 | offer: ProviderContinuationRequests.ProviderContinuationRequest, |
| | 725 | suffix = "wake", |
| | 726 | ) => ({ |
| | 727 | ...withLineage(thread), |
| | 728 | runId: RunId.make(`run:opencode2-adapter:${suffix}`), |
| | 729 | runOrdinal: 2, |
| | 730 | providerTurnOrdinal: 2, |
| | 731 | attemptId: RunAttemptId.make(`attempt:opencode2-adapter:${suffix}`), |
| | 732 | message: { |
| | 733 | ...turnInput(thread).message, |
| | 734 | messageId: offer.messageId!, |
| | 735 | text: offer.detail ?? "", |
| | 736 | createdBy: "agent" as const, |
| | 737 | creationSource: "provider" as const, |
| | 738 | }, |
| | 739 | }); |
| 718 | const CHILD = "ses_f1485c529ffea4URrYruwEg0Ja"; | 740 | const CHILD = "ses_f1485c529ffea4URrYruwEg0Ja"; |
| 719 | /** | 741 | /** |
| 720 | * The single reader of the runtime's events: resolves `attached` once the | 742 | * The single reader of the runtime's events: resolves `attached` once the |
| ... | @@ -1865,7 +1887,8 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { | ... | @@ -1865,7 +1887,8 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { |
| 1865 | | 1887 | |
| 1866 | it.effect("ends a background reply's continuation after a lost stream took a Stop's end", () => | 1888 | it.effect("ends a background reply's continuation after a lost stream took a Stop's end", () => |
| 1867 | Effect.gen(function* () { | 1889 | Effect.gen(function* () { |
| 1868 | const offered = yield* Deferred.make<void>(); | 1890 | const offered = |
| | 1891 | yield* Deferred.make<ProviderContinuationRequests.ProviderContinuationRequest>(); |
| 1869 | const gate = makeProviderReplayGate(["drop", "reply"]); | 1892 | const gate = makeProviderReplayGate(["drop", "reply"]); |
| 1870 | const { runtime, thread } = yield* resumed( | 1893 | const { runtime, thread } = yield* resumed( |
| 1871 | [ | 1894 | [ |
| ... | @@ -1926,7 +1949,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { | ... | @@ -1926,7 +1949,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { |
| 1926 | { replayGate: gate }, | 1949 | { replayGate: gate }, |
| 1927 | ).pipe( | 1950 | ).pipe( |
| 1928 | Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { | 1951 | Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { |
| 1929 | offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), | 1952 | offer: (request) => Deferred.succeed(offered, request).pipe(Effect.asVoid), |
| 1930 | take: Effect.never, | 1953 | take: Effect.never, |
| 1931 | }), | 1954 | }), |
| 1932 | ); | 1955 | ); |
| ... | @@ -1955,26 +1978,133 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { | ... | @@ -1955,26 +1978,133 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { |
| 1955 | yield* Fiber.join(interrupt); | 1978 | yield* Fiber.join(interrupt); |
| 1956 | yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }); | 1979 | yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }); |
| 1957 | gate.release("drop"); | 1980 | gate.release("drop"); |
| 1958 | yield* Deferred.await(offered); | | |
| 1959 | yield* runtime.startTurn({ | 1981 | yield* runtime.startTurn({ |
| 1960 | ...withLineage(thread), | 1982 | ...continuationTurn(thread, yield* Deferred.await(offered)), |
| 1961 | runId: RunId.make("run:opencode2-adapter:wake"), | | |
| 1962 | runOrdinal: 3, | 1983 | runOrdinal: 3, |
| 1963 | providerTurnOrdinal: 3, | 1984 | providerTurnOrdinal: 3, |
| 1964 | attemptId: RunAttemptId.make("attempt:opencode2-adapter:wake"), | | |
| 1965 | message: { | | |
| 1966 | ...turnInput(thread).message, | | |
| 1967 | messageId: MessageId.make("message:opencode2-adapter:wake"), | | |
| 1968 | createdBy: "agent" as const, | | |
| 1969 | creationSource: "provider" as const, | | |
| 1970 | }, | | |
| 1971 | }); | 1985 | }); |
| 1972 | gate.release("reply"); | 1986 | gate.release("reply"); |
| 1973 | // The reply's own end finishes its continuation: the lost Stop's end does not take it. | 1987 | // The reply's own end finishes its continuation: the lost Stop's end does not take it. |
| | 1988 | const events = [...(yield* Fiber.join(collected))]; |
| 1974 | assert.deepEqual( | 1989 | assert.deepEqual( |
| 1975 | [...(yield* Fiber.join(collected))].flatMap((event) => | 1990 | events.flatMap((event) => (event.type === "turn.terminal" ? [event.status] : [])), |
| 1976 | event.type === "turn.terminal" ? [event.status] : [], | 1991 | ["interrupted", "failed", "completed"], |
| | 1992 | ); |
| | 1993 | // The continuation replayed the reply. |
| | 1994 | assert.include( |
| | 1995 | events.flatMap((event) => |
| | 1996 | event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" |
| | 1997 | ? [event.turnItem.text] |
| | 1998 | : [], |
| | 1999 | ), |
| | 2000 | "CHILD_OK", |
| | 2001 | ); |
| | 2002 | }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), |
| | 2003 | ); |
| | 2004 | |
| | 2005 | it.effect("lets a prompt join a background reply after a lost stream took a Stop's end", () => |
| | 2006 | Effect.gen(function* () { |
| | 2007 | const offered = yield* Deferred.make<void>(); |
| | 2008 | const gate = makeProviderReplayGate(["drop"]); |
| | 2009 | const { runtime, thread } = yield* resumed( |
| | 2010 | [ |
| | 2011 | ...backgroundLaunch(CHILD), |
| | 2012 | // Restarting the turn stops only the parent, and the server never answers. |
| | 2013 | out("session.interrupt", { sessionID: SESSION }), |
| | 2014 | reply("session.interrupt", "<hang>"), |
| | 2015 | // The restarted turn finds the parent still stopping and fails. |
| | 2016 | out("session.active"), |
| | 2017 | replyData("session.active", { [SESSION]: { type: "running" } }), |
| | 2018 | out("session.interrupt", { sessionID: SESSION }), |
| | 2019 | reply("session.interrupt", { interrupted: true }), |
| | 2020 | // The stream drops before the parent's end; the reconnect finds it idle. |
| | 2021 | event("session.usage.updated", { sessionID: SESSION }, "drop"), |
| | 2022 | { type: "runtime_exit", status: "success" } as const, |
| | 2023 | out("event.subscribe"), |
| | 2024 | out("session.active"), |
| | 2025 | replyData("session.active", { [CHILD]: { type: "running" } }), |
| | 2026 | out("permission.list", { sessionID: CHILD }), |
| | 2027 | replyData("permission.list", []), |
| | 2028 | out("session.form.list", { sessionID: CHILD }), |
| | 2029 | replyData("session.form.list", []), |
| | 2030 | // The subagent ends and OpenCode answers its report on its own. |
| | 2031 | event("session.execution.succeeded", { sessionID: CHILD }), |
| | 2032 | event("session.inbox.enqueued", { |
| | 2033 | inboxID: "msg_report", |
| | 2034 | sessionID: SESSION, |
| | 2035 | item: { |
| | 2036 | type: "synthetic", |
| | 2037 | payload: { |
| | 2038 | text: `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`, |
| | 2039 | description: "Sleep", |
| | 2040 | metadata: { |
| | 2041 | source: "subagent", |
| | 2042 | childID: CHILD, |
| | 2043 | agent: "General", |
| | 2044 | state: "completed", |
| | 2045 | }, |
| | 2046 | }, |
| | 2047 | delivery: "steer", |
| | 2048 | }, |
| | 2049 | }), |
| | 2050 | event("session.execution.started", { sessionID: SESSION }), |
| | 2051 | // A prompt sent while that reply runs joins it, with no check first. |
| | 2052 | out("session.prompt", { sessionID: SESSION, text: "<any>" }), |
| | 2053 | promptAccepted, |
| | 2054 | event("session.text.ended", { |
| | 2055 | sessionID: SESSION, |
| | 2056 | assistantMessageID: "msg_followup", |
| | 2057 | ordinal: 0, |
| | 2058 | text: "CHILD_OK", |
| | 2059 | }), |
| | 2060 | event("session.execution.succeeded", { sessionID: SESSION }), |
| | 2061 | ], |
| | 2062 | { replayGate: gate }, |
| | 2063 | ).pipe( |
| | 2064 | Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { |
| | 2065 | offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), |
| | 2066 | take: Effect.never, |
| | 2067 | }), |
| | 2068 | ); |
| | 2069 | // The single reader of the runtime's events. |
| | 2070 | const attached = yield* Deferred.make<void>(); |
| | 2071 | const ended = yield* runtime.events.pipe( |
| | 2072 | Stream.tap((event) => |
| | 2073 | event.type === "subagent.updated" && event.subagent.childThreadId !== null |
| | 2074 | ? Deferred.succeed(attached, undefined) |
| | 2075 | : Effect.void, |
| 1977 | ), | 2076 | ), |
| | 2077 | Stream.filter( |
| | 2078 | ( |
| | 2079 | event, |
| | 2080 | ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }> => |
| | 2081 | event.type === "turn.terminal", |
| | 2082 | ), |
| | 2083 | Stream.take(3), |
| | 2084 | Stream.runCollect, |
| | 2085 | Effect.forkScoped, |
| | 2086 | ); |
| | 2087 | yield* runtime.startTurn(withLineage(thread)); |
| | 2088 | yield* Deferred.await(attached); |
| | 2089 | const interrupt = yield* runtime |
| | 2090 | .interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId }) |
| | 2091 | .pipe(Effect.forkScoped); |
| | 2092 | yield* TestClock.adjust("11 seconds"); |
| | 2093 | yield* Fiber.join(interrupt); |
| | 2094 | yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }); |
| | 2095 | gate.release("drop"); |
| | 2096 | // The reply has started. |
| | 2097 | yield* Deferred.await(offered); |
| | 2098 | yield* runtime.startTurn({ |
| | 2099 | ...withLineage(thread), |
| | 2100 | runId: RunId.make("run:opencode2-adapter:3"), |
| | 2101 | runOrdinal: 3, |
| | 2102 | providerTurnOrdinal: 3, |
| | 2103 | attemptId: RunAttemptId.make("attempt:opencode2-adapter:3"), |
| | 2104 | }); |
| | 2105 | // The reply's start cleared the Stop's mark, so its end finishes the joined turn. |
| | 2106 | assert.deepEqual( |
| | 2107 | [...(yield* Fiber.join(ended))].map((terminal) => terminal.status), |
| 1978 | ["interrupted", "failed", "completed"], | 2108 | ["interrupted", "failed", "completed"], |
| 1979 | ); | 2109 | ); |
| 1980 | }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), | 2110 | }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), |
| ... | @@ -1987,7 +2117,8 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { | ... | @@ -1987,7 +2117,8 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { |
| 1987 | */ | 2117 | */ |
| 1988 | const replyAfterUnclearFailure = (endedFirst: boolean) => | 2118 | const replyAfterUnclearFailure = (endedFirst: boolean) => |
| 1989 | Effect.gen(function* () { | 2119 | Effect.gen(function* () { |
| 1990 | const offered = yield* Deferred.make<void>(); | 2120 | const offered = |
| | 2121 | yield* Deferred.make<ProviderContinuationRequests.ProviderContinuationRequest>(); |
| 1991 | const gate = makeProviderReplayGate(endedFirst ? ["wake"] : ["wake", "reply"]); | 2122 | const gate = makeProviderReplayGate(endedFirst ? ["wake"] : ["wake", "reply"]); |
| 1992 | const { runtime, thread } = yield* resumed( | 2123 | const { runtime, thread } = yield* resumed( |
| 1993 | [ | 2124 | [ |
| ... | @@ -2038,7 +2169,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { | ... | @@ -2038,7 +2169,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { |
| 2038 | { replayGate: gate }, | 2169 | { replayGate: gate }, |
| 2039 | ).pipe( | 2170 | ).pipe( |
| 2040 | Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { | 2171 | Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { |
| 2041 | offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), | 2172 | offer: (request) => Deferred.succeed(offered, request).pipe(Effect.asVoid), |
| 2042 | take: Effect.never, | 2173 | take: Effect.never, |
| 2043 | }), | 2174 | }), |
| 2044 | ); | 2175 | ); |
| ... | @@ -2073,21 +2204,12 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { | ... | @@ -2073,21 +2204,12 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { |
| 2073 | .startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }) | 2204 | .startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }) |
| 2074 | .pipe(Effect.ignore); | 2205 | .pipe(Effect.ignore); |
| 2075 | gate.release("wake"); | 2206 | gate.release("wake"); |
| 2076 | yield* Deferred.await(offered); | | |
| 2077 | // Everything up to the subagent's next run has been read: the reply ended. | 2207 | // Everything up to the subagent's next run has been read: the reply ended. |
| 2078 | if (endedFirst) yield* Deferred.await(childRan); | 2208 | if (endedFirst) yield* Deferred.await(childRan); |
| 2079 | yield* runtime.startTurn({ | 2209 | yield* runtime.startTurn({ |
| 2080 | ...withLineage(thread), | 2210 | ...continuationTurn(thread, yield* Deferred.await(offered)), |
| 2081 | runId: RunId.make("run:opencode2-adapter:wake"), | | |
| 2082 | runOrdinal: 3, | 2211 | runOrdinal: 3, |
| 2083 | providerTurnOrdinal: 3, | 2212 | providerTurnOrdinal: 3, |
| 2084 | attemptId: RunAttemptId.make("attempt:opencode2-adapter:wake"), | | |
| 2085 | message: { | | |
| 2086 | ...turnInput(thread).message, | | |
| 2087 | messageId: MessageId.make("message:opencode2-adapter:wake"), | | |
| 2088 | createdBy: "agent" as const, | | |
| 2089 | creationSource: "provider" as const, | | |
| 2090 | }, | | |
| 2091 | }); | 2213 | }); |
| 2092 | if (!endedFirst) gate.release("reply"); | 2214 | if (!endedFirst) gate.release("reply"); |
| 2093 | // The reply's own end finishes its continuation: the failed prompt's mark does not take it. | 2215 | // The reply's own end finishes its continuation: the failed prompt's mark does not take it. |
| ... | @@ -4378,19 +4500,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { | ... | @@ -4378,19 +4500,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { |
| 4378 | yield* Effect.gen(function* () { | 4500 | yield* Effect.gen(function* () { |
| 4379 | while (offers.length === 0) yield* Effect.yieldNow; | 4501 | while (offers.length === 0) yield* Effect.yieldNow; |
| 4380 | }).pipe(Effect.timeout("2 seconds"), Effect.orDie); | 4502 | }).pipe(Effect.timeout("2 seconds"), Effect.orDie); |
| 4381 | yield* runtime.startTurn({ | 4503 | yield* runtime.startTurn(continuationTurn(thread, offers[0]!)); |
| 4382 | ...withLineage(thread), | | |
| 4383 | runId: RunId.make("run:opencode2-adapter:wake"), | | |
| 4384 | runOrdinal: 2, | | |
| 4385 | providerTurnOrdinal: 2, | | |
| 4386 | attemptId: RunAttemptId.make("attempt:opencode2-adapter:wake"), | | |
| 4387 | message: { | | |
| 4388 | ...turnInput(thread).message, | | |
| 4389 | messageId: MessageId.make("message:opencode2-adapter:wake"), | | |
| 4390 | createdBy: "agent" as const, | | |
| 4391 | creationSource: "provider" as const, | | |
| 4392 | }, | | |
| 4393 | }); | | |
| 4394 | yield* Deferred.await(bothEnded); | 4504 | yield* Deferred.await(bothEnded); |
| 4395 | const [first, second] = turns; | 4505 | const [first, second] = turns; |
| 4396 | return { runtime, thread, first: first!, second: second! }; | 4506 | return { runtime, thread, first: first!, second: second! }; |
| ... | @@ -4457,6 +4567,556 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { | ... | @@ -4457,6 +4567,556 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { |
| 4457 | }).pipe(Effect.scoped), | 4567 | }).pipe(Effect.scoped), |
| 4458 | ); | 4568 | ); |
| 4459 | | 4569 | |
| | 4570 | it.effect("keeps a subagent a stopped background reply started off the next turn", () => |
| | 4571 | Effect.gen(function* () { |
| | 4572 | const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB"; |
| | 4573 | const SPAWNED = "ses_f1485c529ffeDDDDDDDDDDDDDD"; |
| | 4574 | const NESTED = "ses_f1485c529ffeNNNNNNNNNNNNNN"; |
| | 4575 | const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" }; |
| | 4576 | const spawn = { sessionID: SESSION, assistantMessageID: "msg_reply", id: "call-spawn" }; |
| | 4577 | const offers: Array<ProviderContinuationRequests.ProviderContinuationRequest> = []; |
| | 4578 | const gate = makeProviderReplayGate(["settled"]); |
| | 4579 | const { runtime, thread } = yield* resumed( |
| | 4580 | [ |
| | 4581 | ...backgroundLaunch(CHILD), |
| | 4582 | // The same turn starts a second background subagent, which runs on. |
| | 4583 | event("session.tool.input.started", { ...toolB, name: "subagent" }), |
| | 4584 | event("session.tool.called", { |
| | 4585 | ...toolB, |
| | 4586 | name: "subagent", |
| | 4587 | input: { description: "Sleep", prompt: "sleep", background: true }, |
| | 4588 | executed: false, |
| | 4589 | }), |
| | 4590 | event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }), |
| | 4591 | event("session.tool.progress", { |
| | 4592 | ...toolB, |
| | 4593 | metadata: { sessionID: CHILD_B, status: "running" }, |
| | 4594 | }), |
| | 4595 | event("session.execution.succeeded", { sessionID: SESSION }), |
| | 4596 | event("session.execution.started", { sessionID: CHILD_B }), |
| | 4597 | // The first subagent's report wakes the parent into a reply T3 holds |
| | 4598 | // for its continuation. |
| | 4599 | event("session.execution.succeeded", { sessionID: CHILD }), |
| | 4600 | event("session.inbox.enqueued", { |
| | 4601 | inboxID: "msg_report", |
| | 4602 | sessionID: SESSION, |
| | 4603 | item: { |
| | 4604 | type: "synthetic", |
| | 4605 | payload: { |
| | 4606 | text: `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`, |
| | 4607 | description: "Sleep", |
| | 4608 | metadata: { |
| | 4609 | source: "subagent", |
| | 4610 | childID: CHILD, |
| | 4611 | agent: "General", |
| | 4612 | state: "completed", |
| | 4613 | }, |
| | 4614 | }, |
| | 4615 | delivery: "steer", |
| | 4616 | }, |
| | 4617 | }), |
| | 4618 | event("session.execution.started", { sessionID: SESSION }), |
| | 4619 | // The reply starts a background subagent of its own, named only by |
| | 4620 | // the reply's held events. |
| | 4621 | event("session.tool.input.started", { ...spawn, name: "subagent" }), |
| | 4622 | event("session.tool.called", { |
| | 4623 | ...spawn, |
| | 4624 | name: "subagent", |
| | 4625 | input: { description: "Sleep", prompt: "sleep", background: true }, |
| | 4626 | executed: false, |
| | 4627 | }), |
| | 4628 | event("session.created", { ...childCreated(SPAWNED), sessionID: SPAWNED }), |
| | 4629 | event("session.tool.progress", { |
| | 4630 | ...spawn, |
| | 4631 | metadata: { sessionID: SPAWNED, status: "running" }, |
| | 4632 | }), |
| | 4633 | event("session.execution.succeeded", { sessionID: SESSION }), |
| | 4634 | // The second subagent's text, shown live, marks that the reply has ended. |
| | 4635 | event("session.text.ended", { |
| | 4636 | sessionID: CHILD_B, |
| | 4637 | assistantMessageID: "msg_child_b", |
| | 4638 | ordinal: 0, |
| | 4639 | text: "B_WORKING", |
| | 4640 | }), |
| | 4641 | // The user's Stop stops the second subagent and drops the held reply. |
| | 4642 | out("session.interrupt", { sessionID: CHILD_B }), |
| | 4643 | reply("session.interrupt", { interrupted: true }), |
| | 4644 | event("session.execution.interrupted", { sessionID: CHILD_B }), |
| | 4645 | // The subagent only the dropped reply named is stopped too; that Stop |
| | 4646 | // fails, so it runs on. |
| | 4647 | out("session.interrupt", { sessionID: SPAWNED }), |
| | 4648 | reply("session.interrupt", { |
| | 4649 | status: 500, |
| | 4650 | body: { _tag: "UnknownError", message: "interrupt failed" }, |
| | 4651 | }), |
| | 4652 | // An unrelated prompt runs while that subagent, and a subagent it |
| | 4653 | // starts, ask for permissions, which no turn shows: they are refused. |
| | 4654 | out("session.prompt", { sessionID: SESSION, text: "<any>" }), |
| | 4655 | promptAccepted, |
| | 4656 | event("session.execution.started", { sessionID: SESSION }), |
| | 4657 | event("permission.asked", { ...shellAsk.data, id: "per_spawned", sessionID: SPAWNED }), |
| | 4658 | out("permission.reply", { |
| | 4659 | sessionID: SPAWNED, |
| | 4660 | requestID: "per_spawned", |
| | 4661 | decision: "reject", |
| | 4662 | }), |
| | 4663 | reply("permission.reply", null), |
| | 4664 | event("session.created", { |
| | 4665 | ...childCreated(NESTED), |
| | 4666 | sessionID: NESTED, |
| | 4667 | parentID: SPAWNED, |
| | 4668 | }), |
| | 4669 | event("permission.asked", { ...shellAsk.data, id: "per_nested", sessionID: NESTED }), |
| | 4670 | out("permission.reply", { |
| | 4671 | sessionID: NESTED, |
| | 4672 | requestID: "per_nested", |
| | 4673 | decision: "reject", |
| | 4674 | }), |
| | 4675 | reply("permission.reply", null), |
| | 4676 | event("session.text.ended", { |
| | 4677 | sessionID: SESSION, |
| | 4678 | assistantMessageID: "msg_unrelated", |
| | 4679 | ordinal: 0, |
| | 4680 | text: "UNRELATED", |
| | 4681 | }), |
| | 4682 | event("session.execution.succeeded", { sessionID: SESSION }), |
| | 4683 | // The next Stop tries the subagent again, and the one it started, and reaches both. |
| | 4684 | out("session.interrupt", { sessionID: SPAWNED }), |
| | 4685 | reply("session.interrupt", { interrupted: true }), |
| | 4686 | out("session.interrupt", { sessionID: NESTED }), |
| | 4687 | reply("session.interrupt", { interrupted: true }), |
| | 4688 | // OpenCode reports the stopped subagent to the thread's session and |
| | 4689 | // wakes it; that execution answers no turn, so it is stopped and no |
| | 4690 | // continuation is offered for it. |
| | 4691 | event("session.execution.interrupted", { sessionID: SPAWNED }), |
| | 4692 | event("session.inbox.enqueued", { |
| | 4693 | inboxID: "msg_report_spawned", |
| | 4694 | sessionID: SESSION, |
| | 4695 | item: { |
| | 4696 | type: "synthetic", |
| | 4697 | payload: { |
| | 4698 | text: `<subagent sessionID="${SPAWNED}" state="cancelled" description="Sleep">\n</subagent>`, |
| | 4699 | description: "Sleep", |
| | 4700 | metadata: { |
| | 4701 | source: "subagent", |
| | 4702 | childID: SPAWNED, |
| | 4703 | agent: "General", |
| | 4704 | state: "cancelled", |
| | 4705 | }, |
| | 4706 | }, |
| | 4707 | delivery: "steer", |
| | 4708 | }, |
| | 4709 | }), |
| | 4710 | event("session.execution.started", { sessionID: SESSION }), |
| | 4711 | out("session.interrupt", { sessionID: SESSION }), |
| | 4712 | reply("session.interrupt", { interrupted: true }), |
| | 4713 | event("session.execution.interrupted", { sessionID: SESSION }, "settled"), |
| | 4714 | ], |
| | 4715 | { replayGate: gate }, |
| | 4716 | ).pipe( |
| | 4717 | Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { |
| | 4718 | offer: (request) => Effect.sync(() => void offers.push(request)), |
| | 4719 | take: Effect.never, |
| | 4720 | }), |
| | 4721 | ); |
| | 4722 | const collected: Array<ProviderAdapter.ProviderAdapterV2Event> = []; |
| | 4723 | const replyEnded = yield* Deferred.make<void>(); |
| | 4724 | const bothEnded = yield* Deferred.make<void>(); |
| | 4725 | yield* runtime.events.pipe( |
| | 4726 | Stream.tap((event) => |
| | 4727 | Effect.gen(function* () { |
| | 4728 | collected.push(event); |
| | 4729 | if (event.type === "turn_item.updated" && event.turnItem.type === "assistant_message") { |
| | 4730 | if (event.turnItem.text === "B_WORKING") |
| | 4731 | yield* Deferred.succeed(replyEnded, undefined); |
| | 4732 | } |
| | 4733 | if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) { |
| | 4734 | yield* Deferred.succeed(bothEnded, undefined); |
| | 4735 | } |
| | 4736 | }), |
| | 4737 | ), |
| | 4738 | Stream.runDrain, |
| | 4739 | Effect.forkScoped, |
| | 4740 | ); |
| | 4741 | yield* runtime.startTurn(withLineage(thread)); |
| | 4742 | yield* Deferred.await(replyEnded); |
| | 4743 | assert.lengthOf(offers, 1); |
| | 4744 | // The user stops the thread before the reply's continuation runs. |
| | 4745 | yield* runtime.interruptTurn({ |
| | 4746 | providerThread: thread, |
| | 4747 | providerTurnId: yield* providerTurnId, |
| | 4748 | requestRuntimeRestart: true, |
| | 4749 | }); |
| | 4750 | yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }); |
| | 4751 | yield* Deferred.await(bothEnded); |
| | 4752 | // A later Stop reaches both subagents; once stopped they are not tried |
| | 4753 | // again (the replay fails on any further request). |
| | 4754 | for (let stops = 0; stops < 2; stops++) { |
| | 4755 | yield* runtime.interruptTurn({ |
| | 4756 | providerThread: thread, |
| | 4757 | providerTurnId: yield* providerTurnId, |
| | 4758 | requestRuntimeRestart: true, |
| | 4759 | }); |
| | 4760 | } |
| | 4761 | // The stopped subagent's report wakes nothing T3 runs. |
| | 4762 | yield* Effect.promise(() => gate.waitForReached("settled")); |
| | 4763 | gate.release("settled"); |
| | 4764 | assert.lengthOf(offers, 1); |
| | 4765 | // The unrelated turn shows no request it never made. |
| | 4766 | assert.deepEqual( |
| | 4767 | collected.flatMap((event) => |
| | 4768 | event.type === "runtime_request.updated" |
| | 4769 | ? [`${event.runtimeRequest.nativeRequestRef?.nativeId}:${event.runtimeRequest.status}`] |
| | 4770 | : [], |
| | 4771 | ), |
| | 4772 | [], |
| | 4773 | ); |
| | 4774 | }).pipe(Effect.scoped), |
| | 4775 | ); |
| | 4776 | |
| | 4777 | it.effect( |
| | 4778 | "keeps every subagent a stopped background reply's subagent started off the next turn", |
| | 4779 | () => |
| | 4780 | Effect.gen(function* () { |
| | 4781 | const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB"; |
| | 4782 | const SPAWNED = "ses_f1485c529ffeDDDDDDDDDDDDDD"; |
| | 4783 | const NESTED = "ses_f1485c529ffeNNNNNNNNNNNNNN"; |
| | 4784 | const DEEP = "ses_f1485c529ffeEEEEEEEEEEEEEE"; |
| | 4785 | const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" }; |
| | 4786 | const spawn = { sessionID: SESSION, assistantMessageID: "msg_reply", id: "call-spawn" }; |
| | 4787 | const { runtime, thread } = yield* resumed([ |
| | 4788 | ...backgroundLaunch(CHILD), |
| | 4789 | // The same turn starts a second background subagent, which runs on. |
| | 4790 | event("session.tool.input.started", { ...toolB, name: "subagent" }), |
| | 4791 | event("session.tool.called", { |
| | 4792 | ...toolB, |
| | 4793 | name: "subagent", |
| | 4794 | input: { description: "Sleep", prompt: "sleep", background: true }, |
| | 4795 | executed: false, |
| | 4796 | }), |
| | 4797 | event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }), |
| | 4798 | event("session.tool.progress", { |
| | 4799 | ...toolB, |
| | 4800 | metadata: { sessionID: CHILD_B, status: "running" }, |
| | 4801 | }), |
| | 4802 | event("session.execution.succeeded", { sessionID: SESSION }), |
| | 4803 | event("session.execution.started", { sessionID: CHILD_B }), |
| | 4804 | // The first subagent's report wakes the parent into a reply T3 holds. |
| | 4805 | event("session.execution.succeeded", { sessionID: CHILD }), |
| | 4806 | event("session.inbox.enqueued", { |
| | 4807 | inboxID: "msg_report", |
| | 4808 | sessionID: SESSION, |
| | 4809 | item: { |
| | 4810 | type: "synthetic", |
| | 4811 | payload: { |
| | 4812 | text: `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`, |
| | 4813 | description: "Sleep", |
| | 4814 | metadata: { |
| | 4815 | source: "subagent", |
| | 4816 | childID: CHILD, |
| | 4817 | agent: "General", |
| | 4818 | state: "completed", |
| | 4819 | }, |
| | 4820 | }, |
| | 4821 | delivery: "steer", |
| | 4822 | }, |
| | 4823 | }), |
| | 4824 | event("session.execution.started", { sessionID: SESSION }), |
| | 4825 | // The held reply starts a subagent, which starts one, which starts another. |
| | 4826 | event("session.tool.input.started", { ...spawn, name: "subagent" }), |
| | 4827 | event("session.tool.called", { |
| | 4828 | ...spawn, |
| | 4829 | name: "subagent", |
| | 4830 | input: { description: "Sleep", prompt: "sleep", background: true }, |
| | 4831 | executed: false, |
| | 4832 | }), |
| | 4833 | event("session.created", { ...childCreated(SPAWNED), sessionID: SPAWNED }), |
| | 4834 | event("session.tool.progress", { |
| | 4835 | ...spawn, |
| | 4836 | metadata: { sessionID: SPAWNED, status: "running" }, |
| | 4837 | }), |
| | 4838 | event("session.created", { |
| | 4839 | ...childCreated(NESTED), |
| | 4840 | sessionID: NESTED, |
| | 4841 | parentID: SPAWNED, |
| | 4842 | }), |
| | 4843 | event("session.created", { ...childCreated(DEEP), sessionID: DEEP, parentID: NESTED }), |
| | 4844 | event("session.execution.succeeded", { sessionID: SESSION }), |
| | 4845 | // The second subagent's text, shown live, marks that the reply has ended. |
| | 4846 | event("session.text.ended", { |
| | 4847 | sessionID: CHILD_B, |
| | 4848 | assistantMessageID: "msg_child_b", |
| | 4849 | ordinal: 0, |
| | 4850 | text: "B_WORKING", |
| | 4851 | }), |
| | 4852 | // The user's Stop stops the second subagent and drops the held reply, |
| | 4853 | // then each subagent under the reply, the deepest of which runs on. |
| | 4854 | out("session.interrupt", { sessionID: CHILD_B }), |
| | 4855 | reply("session.interrupt", { interrupted: true }), |
| | 4856 | event("session.execution.interrupted", { sessionID: CHILD_B }), |
| | 4857 | out("session.interrupt", { sessionID: SPAWNED }), |
| | 4858 | reply("session.interrupt", { interrupted: true }), |
| | 4859 | out("session.interrupt", { sessionID: NESTED }), |
| | 4860 | reply("session.interrupt", { interrupted: true }), |
| | 4861 | out("session.interrupt", { sessionID: DEEP }), |
| | 4862 | reply("session.interrupt", { |
| | 4863 | status: 500, |
| | 4864 | body: { _tag: "UnknownError", message: "interrupt failed" }, |
| | 4865 | }), |
| | 4866 | // An unrelated prompt runs while the deepest asks for a permission, |
| | 4867 | // which no turn shows: it is refused. |
| | 4868 | out("session.prompt", { sessionID: SESSION, text: "<any>" }), |
| | 4869 | promptAccepted, |
| | 4870 | event("session.execution.started", { sessionID: SESSION }), |
| | 4871 | event("permission.asked", { ...shellAsk.data, id: "per_deep", sessionID: DEEP }), |
| | 4872 | out("permission.reply", { sessionID: DEEP, requestID: "per_deep", decision: "reject" }), |
| | 4873 | reply("permission.reply", null), |
| | 4874 | event("session.execution.succeeded", { sessionID: SESSION }), |
| | 4875 | ]); |
| | 4876 | const collected: Array<ProviderAdapter.ProviderAdapterV2Event> = []; |
| | 4877 | const replyEnded = yield* Deferred.make<void>(); |
| | 4878 | const bothEnded = yield* Deferred.make<void>(); |
| | 4879 | yield* runtime.events.pipe( |
| | 4880 | Stream.tap((event) => |
| | 4881 | Effect.gen(function* () { |
| | 4882 | collected.push(event); |
| | 4883 | if ( |
| | 4884 | event.type === "turn_item.updated" && |
| | 4885 | event.turnItem.type === "assistant_message" && |
| | 4886 | event.turnItem.text === "B_WORKING" |
| | 4887 | ) { |
| | 4888 | yield* Deferred.succeed(replyEnded, undefined); |
| | 4889 | } |
| | 4890 | if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) { |
| | 4891 | yield* Deferred.succeed(bothEnded, undefined); |
| | 4892 | } |
| | 4893 | }), |
| | 4894 | ), |
| | 4895 | Stream.runDrain, |
| | 4896 | Effect.forkScoped, |
| | 4897 | ); |
| | 4898 | yield* runtime.startTurn(withLineage(thread)); |
| | 4899 | yield* Deferred.await(replyEnded); |
| | 4900 | yield* runtime.interruptTurn({ |
| | 4901 | providerThread: thread, |
| | 4902 | providerTurnId: yield* providerTurnId, |
| | 4903 | requestRuntimeRestart: true, |
| | 4904 | }); |
| | 4905 | yield* runtime.startTurn({ |
| | 4906 | ...secondTurn(thread), |
| | 4907 | appThread: withLineage(thread).appThread, |
| | 4908 | }); |
| | 4909 | yield* Deferred.await(bothEnded); |
| | 4910 | // The unrelated turn shows no request it never made. |
| | 4911 | assert.deepEqual( |
| | 4912 | collected.flatMap((event) => |
| | 4913 | event.type === "runtime_request.updated" |
| | 4914 | ? [ |
| | 4915 | `${event.runtimeRequest.nativeRequestRef?.nativeId}:${event.runtimeRequest.status}`, |
| | 4916 | ] |
| | 4917 | : [], |
| | 4918 | ), |
| | 4919 | [], |
| | 4920 | ); |
| | 4921 | }).pipe(Effect.scoped), |
| | 4922 | ); |
| | 4923 | |
| | 4924 | it.effect("ends a continuation while a skipped reply's subagent does not answer its Stop", () => |
| | 4925 | Effect.gen(function* () { |
| | 4926 | const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB"; |
| | 4927 | const SPAWNED = "ses_f1485c529ffeDDDDDDDDDDDDDD"; |
| | 4928 | const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" }; |
| | 4929 | const spawn = { sessionID: SESSION, assistantMessageID: "msg_reply_a", id: "call-spawn" }; |
| | 4930 | const report = (inboxID: string, child: string) => |
| | 4931 | event("session.inbox.enqueued", { |
| | 4932 | inboxID, |
| | 4933 | sessionID: SESSION, |
| | 4934 | item: { |
| | 4935 | type: "synthetic", |
| | 4936 | payload: { |
| | 4937 | text: `<subagent sessionID="${child}" state="completed" description="Sleep">\nOK\n</subagent>`, |
| | 4938 | description: "Sleep", |
| | 4939 | metadata: { |
| | 4940 | source: "subagent", |
| | 4941 | childID: child, |
| | 4942 | agent: "General", |
| | 4943 | state: "completed", |
| | 4944 | }, |
| | 4945 | }, |
| | 4946 | delivery: "steer", |
| | 4947 | }, |
| | 4948 | }); |
| | 4949 | const offers: Array<ProviderContinuationRequests.ProviderContinuationRequest> = []; |
| | 4950 | const bothOffered = yield* Deferred.make<void>(); |
| | 4951 | const { runtime, thread } = yield* resumed([ |
| | 4952 | ...backgroundLaunch(CHILD), |
| | 4953 | event("session.tool.input.started", { ...toolB, name: "subagent" }), |
| | 4954 | event("session.tool.called", { |
| | 4955 | ...toolB, |
| | 4956 | name: "subagent", |
| | 4957 | input: { description: "Sleep", prompt: "sleep", background: true }, |
| | 4958 | executed: false, |
| | 4959 | }), |
| | 4960 | event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }), |
| | 4961 | event("session.tool.progress", { |
| | 4962 | ...toolB, |
| | 4963 | metadata: { sessionID: CHILD_B, status: "running" }, |
| | 4964 | }), |
| | 4965 | event("session.execution.succeeded", { sessionID: SESSION }), |
| | 4966 | // Reply A starts a subagent of its own, named only by its held events. |
| | 4967 | event("session.execution.succeeded", { sessionID: CHILD }), |
| | 4968 | report("msg_report_a", CHILD), |
| | 4969 | event("session.execution.started", { sessionID: SESSION }), |
| | 4970 | event("session.tool.input.started", { ...spawn, name: "subagent" }), |
| | 4971 | event("session.tool.called", { |
| | 4972 | ...spawn, |
| | 4973 | name: "subagent", |
| | 4974 | input: { description: "Sleep", prompt: "sleep", background: true }, |
| | 4975 | executed: false, |
| | 4976 | }), |
| | 4977 | event("session.created", { ...childCreated(SPAWNED), sessionID: SPAWNED }), |
| | 4978 | event("session.execution.succeeded", { sessionID: SESSION }), |
| | 4979 | event("session.execution.succeeded", { sessionID: CHILD_B }), |
| | 4980 | report("msg_report_b", CHILD_B), |
| | 4981 | event("session.execution.started", { sessionID: SESSION }), |
| | 4982 | event("session.text.ended", { |
| | 4983 | sessionID: SESSION, |
| | 4984 | assistantMessageID: "msg_reply_b", |
| | 4985 | ordinal: 0, |
| | 4986 | text: "REPLY_B", |
| | 4987 | }), |
| | 4988 | event("session.execution.succeeded", { sessionID: SESSION }), |
| | 4989 | // B's continuation skips reply A and stops its subagent, which never answers. |
| | 4990 | out("session.interrupt", { sessionID: SPAWNED }), |
| | 4991 | reply("session.interrupt", "<hang>"), |
| | 4992 | ]).pipe( |
| | 4993 | Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { |
| | 4994 | offer: (request) => |
| | 4995 | Effect.suspend(() => |
| | 4996 | offers.push(request) === 2 |
| | 4997 | ? Deferred.succeed(bothOffered, undefined).pipe(Effect.asVoid) |
| | 4998 | : Effect.void, |
| | 4999 | ), |
| | 5000 | take: Effect.never, |
| | 5001 | }), |
| | 5002 | ); |
| | 5003 | const ended = yield* terminals(runtime, 2); |
| | 5004 | yield* runtime.startTurn(withLineage(thread)); |
| | 5005 | yield* Deferred.await(bothOffered); |
| | 5006 | yield* runtime.startTurn(continuationTurn(thread, offers[1]!, "wake-b")); |
| | 5007 | // B's reply replays and ends its turn while that Stop still waits. |
| | 5008 | assert.deepEqual( |
| | 5009 | [...(yield* Fiber.join(ended))].map((terminal) => terminal.status), |
| | 5010 | ["completed", "completed"], |
| | 5011 | ); |
| | 5012 | }).pipe(Effect.scoped), |
| | 5013 | ); |
| | 5014 | |
| | 5015 | it.effect("replays only its own background reply into a continuation turn", () => |
| | 5016 | Effect.gen(function* () { |
| | 5017 | const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB"; |
| | 5018 | const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" }; |
| | 5019 | const report = (inboxID: string, child: string, text: string) => |
| | 5020 | event("session.inbox.enqueued", { |
| | 5021 | inboxID, |
| | 5022 | sessionID: SESSION, |
| | 5023 | item: { |
| | 5024 | type: "synthetic", |
| | 5025 | payload: { |
| | 5026 | text: `<subagent sessionID="${child}" state="completed" description="Sleep">\n${text}\n</subagent>`, |
| | 5027 | description: "Sleep", |
| | 5028 | metadata: { |
| | 5029 | source: "subagent", |
| | 5030 | childID: child, |
| | 5031 | agent: "General", |
| | 5032 | state: "completed", |
| | 5033 | }, |
| | 5034 | }, |
| | 5035 | delivery: "steer", |
| | 5036 | }, |
| | 5037 | }); |
| | 5038 | const reply = (assistantMessageID: string, text: string) => |
| | 5039 | event("session.text.ended", { sessionID: SESSION, assistantMessageID, ordinal: 0, text }); |
| | 5040 | const offers: Array<ProviderContinuationRequests.ProviderContinuationRequest> = []; |
| | 5041 | const bothOffered = yield* Deferred.make<void>(); |
| | 5042 | const { runtime, thread } = yield* resumed([ |
| | 5043 | ...backgroundLaunch(CHILD), |
| | 5044 | // The same turn starts a second background subagent. |
| | 5045 | event("session.tool.input.started", { ...toolB, name: "subagent" }), |
| | 5046 | event("session.tool.called", { |
| | 5047 | ...toolB, |
| | 5048 | name: "subagent", |
| | 5049 | input: { description: "Sleep", prompt: "sleep", background: true }, |
| | 5050 | executed: false, |
| | 5051 | }), |
| | 5052 | event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }), |
| | 5053 | event("session.tool.progress", { |
| | 5054 | ...toolB, |
| | 5055 | metadata: { sessionID: CHILD_B, status: "running" }, |
| | 5056 | }), |
| | 5057 | event("session.execution.succeeded", { sessionID: SESSION }), |
| | 5058 | // Each subagent's report wakes the parent into a reply OpenCode runs on its own. |
| | 5059 | event("session.execution.succeeded", { sessionID: CHILD }), |
| | 5060 | report("msg_report_a", CHILD, "A_OK"), |
| | 5061 | event("session.execution.started", { sessionID: SESSION }), |
| | 5062 | event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_report_a" }), |
| | 5063 | reply("msg_reply_a", "REPLY_A"), |
| | 5064 | event("session.execution.succeeded", { sessionID: SESSION }), |
| | 5065 | event("session.execution.succeeded", { sessionID: CHILD_B }), |
| | 5066 | report("msg_report_b", CHILD_B, "B_OK"), |
| | 5067 | event("session.execution.started", { sessionID: SESSION }), |
| | 5068 | event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_report_b" }), |
| | 5069 | reply("msg_reply_b", "REPLY_B"), |
| | 5070 | event("session.execution.succeeded", { sessionID: SESSION }), |
| | 5071 | ]).pipe( |
| | 5072 | Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { |
| | 5073 | offer: (request) => |
| | 5074 | Effect.suspend(() => |
| | 5075 | offers.push(request) === 2 |
| | 5076 | ? Deferred.succeed(bothOffered, undefined).pipe(Effect.asVoid) |
| | 5077 | : Effect.void, |
| | 5078 | ), |
| | 5079 | take: Effect.never, |
| | 5080 | }), |
| | 5081 | ); |
| | 5082 | const collected: Array<ProviderAdapter.ProviderAdapterV2Event> = []; |
| | 5083 | const ended = yield* Deferred.make<void>(); |
| | 5084 | yield* runtime.events.pipe( |
| | 5085 | Stream.tap((event) => |
| | 5086 | Effect.gen(function* () { |
| | 5087 | collected.push(event); |
| | 5088 | if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) { |
| | 5089 | yield* Deferred.succeed(ended, undefined); |
| | 5090 | } |
| | 5091 | }), |
| | 5092 | ), |
| | 5093 | Stream.runDrain, |
| | 5094 | Effect.forkScoped, |
| | 5095 | ); |
| | 5096 | yield* runtime.startTurn(withLineage(thread)); |
| | 5097 | yield* Deferred.await(bothOffered); |
| | 5098 | // A's continuation was cancelled before it started; B's runs. |
| | 5099 | yield* runtime.startTurn(continuationTurn(thread, offers[1]!, "wake-b")); |
| | 5100 | yield* Deferred.await(ended); |
| | 5101 | const continuationId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ |
| | 5102 | driver: OPENCODE_PROVIDER, |
| | 5103 | nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter:wake-b`, |
| | 5104 | }); |
| | 5105 | assert.deepEqual( |
| | 5106 | collected.flatMap((event) => |
| | 5107 | event.type === "turn_item.updated" && |
| | 5108 | event.turnItem.type === "assistant_message" && |
| | 5109 | event.turnItem.providerTurnId === continuationId |
| | 5110 | ? [event.turnItem.text] |
| | 5111 | : [], |
| | 5112 | ), |
| | 5113 | ["REPLY_B"], |
| | 5114 | ); |
| | 5115 | // Nothing is left held for a continuation that will never run. |
| | 5116 | assert.isFalse(yield* runtime.hasPendingBackgroundWork!); |
| | 5117 | }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)), |
| | 5118 | ); |
| | 5119 | |
| 4460 | it.effect("ends a follow-up turn a steer joined exactly once", () => | 5120 | it.effect("ends a follow-up turn a steer joined exactly once", () => |
| 4461 | Effect.gen(function* () { | 5121 | Effect.gen(function* () { |
| 4462 | const reportText = `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`; | 5122 | const reportText = `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`; |
| ... | @@ -4534,19 +5194,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { | ... | @@ -4534,19 +5194,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { |
| 4534 | yield* Effect.gen(function* () { | 5194 | yield* Effect.gen(function* () { |
| 4535 | while (offers.length === 0) yield* Effect.yieldNow; | 5195 | while (offers.length === 0) yield* Effect.yieldNow; |
| 4536 | }).pipe(Effect.timeout("2 seconds"), Effect.orDie); | 5196 | }).pipe(Effect.timeout("2 seconds"), Effect.orDie); |
| 4537 | const followUp = { | 5197 | const followUp = continuationTurn(thread, offers[0]!); |
| 4538 | ...withLineage(thread), | | |
| 4539 | runId: RunId.make("run:opencode2-adapter:wake"), | | |
| 4540 | runOrdinal: 2, | | |
| 4541 | providerTurnOrdinal: 2, | | |
| 4542 | attemptId: RunAttemptId.make("attempt:opencode2-adapter:wake"), | | |
| 4543 | message: { | | |
| 4544 | ...turnInput(thread).message, | | |
| 4545 | messageId: MessageId.make("message:opencode2-adapter:wake"), | | |
| 4546 | createdBy: "agent" as const, | | |
| 4547 | creationSource: "provider" as const, | | |
| 4548 | }, | | |
| 4549 | }; | | |
| 4550 | yield* runtime.startTurn(followUp); | 5198 | yield* runtime.startTurn(followUp); |
| 4551 | const followUpTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ | 5199 | const followUpTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ |
| 4552 | driver: OPENCODE_PROVIDER, | 5200 | driver: OPENCODE_PROVIDER, |