| ... | ... | @@ -48,6 +48,10 @@ import { |
| 48 | 48 | t3McpServerName, |
| 49 | 49 | } from "@t3tools/provider-opencode/testing"; |
| 50 | 50 | import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; |
| 51 | import { |
| 52 | makeProviderReplayGate, |
| 53 | type ProviderReplayGate, |
| 54 | } from "@t3tools/provider-testing/replayGate"; |
| 51 | 55 | |
| 52 | 56 | const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; |
| 53 | 57 | const WORK = "/work/opencode2"; |
| ... | ... | @@ -64,12 +68,18 @@ const reply = (operation: string, data: unknown): ProviderReplayEntry => ({ |
| 64 | 68 | frame: { type: "sdk.response", operation, data }, |
| 65 | 69 | }); |
| 66 | 70 | const replyData = (operation: string, data: unknown) => reply(operation, { data }); |
| 67 | | const event = (type: string, data: Record<string, unknown>): ProviderReplayEntry => ({ |
| 71 | const event = ( |
| 72 | type: string, |
| 73 | data: Record<string, unknown>, |
| 74 | /** Holds the event until a test's replay gate releases this label. */ |
| 75 | label?: string, |
| 76 | ): ProviderReplayEntry => ({ |
| 68 | 77 | type: "emit_inbound", |
| 69 | 78 | frame: { |
| 70 | 79 | type: "sdk.event", |
| 71 | 80 | event: { id: `evt_${type.replaceAll(".", "")}0000`, created: 1, type, data, ...durable }, |
| 72 | 81 | }, |
| 82 | ...(label === undefined ? {} : { label }), |
| 73 | 83 | }); |
| 74 | 84 | const durable = { durable: { aggregateID: SESSION, seq: 1, version: 1 } }; |
| 75 | 85 | |
| ... | ... | @@ -316,7 +326,11 @@ const colorForm = { |
| 316 | 326 | */ |
| 317 | 327 | const resumed = ( |
| 318 | 328 | entries: ReadonlyArray<ProviderReplayEntry>, |
| 319 | | options?: { readonly external?: boolean; readonly supervised?: boolean }, |
| 329 | options?: { |
| 330 | readonly external?: boolean; |
| 331 | readonly supervised?: boolean; |
| 332 | readonly replayGate?: ProviderReplayGate; |
| 333 | }, |
| 320 | 334 | ) => |
| 321 | 335 | Effect.gen(function* () { |
| 322 | 336 | const runtime = yield* openCode2ReplayRuntime( |
| ... | ... | @@ -335,7 +349,10 @@ const resumed = ( |
| 335 | 349 | : []), |
| 336 | 350 | ...entries, |
| 337 | 351 | ]), |
| 338 | | options?.external === undefined ? undefined : { external: options.external }, |
| 352 | { |
| 353 | ...(options?.external === undefined ? {} : { external: options.external }), |
| 354 | ...(options?.replayGate === undefined ? {} : { replayGate: options.replayGate }), |
| 355 | }, |
| 339 | 356 | ); |
| 340 | 357 | const thread = yield* runtime.resumeThread({ |
| 341 | 358 | providerThread: providerThread(yield* DateTime.now), |
| ... | ... | @@ -1724,6 +1741,88 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { |
| 1724 | 1741 | }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), |
| 1725 | 1742 | ); |
| 1726 | 1743 | |
| 1744 | it.effect("still checks the server when a failed prompt's execution starts late", () => |
| 1745 | Effect.gen(function* () { |
| 1746 | const offered = yield* Deferred.make<void>(); |
| 1747 | const { runtime, thread } = yield* resumed([ |
| 1748 | out("session.prompt", { sessionID: SESSION, text: "<any>" }), |
| 1749 | // The request failed, but the server took the prompt: its execution |
| 1750 | // starts only after the turn already failed. |
| 1751 | reply("session.prompt", { |
| 1752 | status: 502, |
| 1753 | body: { _tag: "UnknownError", message: "bad gateway" }, |
| 1754 | }), |
| 1755 | event("session.execution.started", { sessionID: SESSION }), |
| 1756 | // That run is still going, so the next turn stops it and fails. |
| 1757 | out("session.active"), |
| 1758 | reply("session.active", { data: { [SESSION]: { type: "running" } } }), |
| 1759 | out("session.interrupt", { sessionID: SESSION }), |
| 1760 | reply("session.interrupt", { interrupted: true }), |
| 1761 | ]).pipe( |
| 1762 | Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { |
| 1763 | offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), |
| 1764 | take: Effect.never, |
| 1765 | }), |
| 1766 | ); |
| 1767 | const ended = yield* terminals(runtime, 2); |
| 1768 | yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore); |
| 1769 | // The late start has been read. |
| 1770 | yield* Deferred.await(offered); |
| 1771 | yield* runtime.startTurn(secondTurn(thread)); |
| 1772 | const [first, second] = yield* Fiber.join(ended); |
| 1773 | assert.equal(first?.status, "failed"); |
| 1774 | assert.equal(second?.failure?.message, OPENCODE_2_STILL_STOPPING); |
| 1775 | }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), |
| 1776 | ); |
| 1777 | |
| 1778 | it.effect("still checks the server when a failed prompt starts after a reconnect", () => |
| 1779 | Effect.gen(function* () { |
| 1780 | const offered = yield* Deferred.make<void>(); |
| 1781 | const gate = makeProviderReplayGate(["drop"]); |
| 1782 | const { runtime, thread } = yield* resumed( |
| 1783 | [ |
| 1784 | out("session.prompt", { sessionID: SESSION, text: "<any>" }), |
| 1785 | reply("session.prompt", { |
| 1786 | status: 502, |
| 1787 | body: { _tag: "UnknownError", message: "bad gateway" }, |
| 1788 | }), |
| 1789 | // The stream drops before the prompt's execution starts, and the |
| 1790 | // reconnect finds the session active: the prompt's run is still to come. |
| 1791 | event("session.usage.updated", { sessionID: SESSION }, "drop"), |
| 1792 | { type: "runtime_exit", status: "success" } as const, |
| 1793 | out("event.subscribe"), |
| 1794 | out("session.active"), |
| 1795 | replyData("session.active", { [SESSION]: { type: "running" } }), |
| 1796 | out("permission.list", { sessionID: SESSION }), |
| 1797 | replyData("permission.list", []), |
| 1798 | out("session.form.list", { sessionID: SESSION }), |
| 1799 | replyData("session.form.list", []), |
| 1800 | // Its own start: that run is still going, so the next turn stops it and fails. |
| 1801 | event("session.execution.started", { sessionID: SESSION }), |
| 1802 | out("session.active"), |
| 1803 | reply("session.active", { data: { [SESSION]: { type: "running" } } }), |
| 1804 | out("session.interrupt", { sessionID: SESSION }), |
| 1805 | reply("session.interrupt", { interrupted: true }), |
| 1806 | ], |
| 1807 | { replayGate: gate }, |
| 1808 | ).pipe( |
| 1809 | Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { |
| 1810 | offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), |
| 1811 | take: Effect.never, |
| 1812 | }), |
| 1813 | ); |
| 1814 | const ended = yield* terminals(runtime, 2); |
| 1815 | yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore); |
| 1816 | gate.release("drop"); |
| 1817 | // The late start has been read. |
| 1818 | yield* Deferred.await(offered); |
| 1819 | yield* runtime.startTurn(secondTurn(thread)); |
| 1820 | const [first, second] = yield* Fiber.join(ended); |
| 1821 | assert.equal(first?.status, "failed"); |
| 1822 | assert.equal(second?.failure?.message, OPENCODE_2_STILL_STOPPING); |
| 1823 | }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), |
| 1824 | ); |
| 1825 | |
| 1727 | 1826 | it.effect("prompts again without a check after the server refused a prompt", () => |
| 1728 | 1827 | Effect.gen(function* () { |
| 1729 | 1828 | const { runtime, thread } = yield* resumed([ |
| ... | ... | @@ -1764,6 +1863,257 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { |
| 1764 | 1863 | }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), |
| 1765 | 1864 | ); |
| 1766 | 1865 | |
| 1866 | it.effect("ends a background reply's continuation after a lost stream took a Stop's end", () => |
| 1867 | Effect.gen(function* () { |
| 1868 | const offered = yield* Deferred.make<void>(); |
| 1869 | const gate = makeProviderReplayGate(["drop", "reply"]); |
| 1870 | const { runtime, thread } = yield* resumed( |
| 1871 | [ |
| 1872 | ...backgroundLaunch(CHILD), |
| 1873 | // Restarting the turn stops only the parent, and the server never answers. |
| 1874 | out("session.interrupt", { sessionID: SESSION }), |
| 1875 | reply("session.interrupt", "<hang>"), |
| 1876 | // The restarted turn finds the parent still stopping and fails. |
| 1877 | out("session.active"), |
| 1878 | replyData("session.active", { [SESSION]: { type: "running" } }), |
| 1879 | out("session.interrupt", { sessionID: SESSION }), |
| 1880 | reply("session.interrupt", { interrupted: true }), |
| 1881 | // The stream drops before the parent's end; the reconnect finds it idle. |
| 1882 | event("session.usage.updated", { sessionID: SESSION }, "drop"), |
| 1883 | { type: "runtime_exit", status: "success" } as const, |
| 1884 | out("event.subscribe"), |
| 1885 | out("session.active"), |
| 1886 | replyData("session.active", { [CHILD]: { type: "running" } }), |
| 1887 | out("permission.list", { sessionID: CHILD }), |
| 1888 | replyData("permission.list", []), |
| 1889 | out("session.form.list", { sessionID: CHILD }), |
| 1890 | replyData("session.form.list", []), |
| 1891 | // The subagent ends and OpenCode answers its report on its own. |
| 1892 | event("session.execution.succeeded", { sessionID: CHILD }), |
| 1893 | event("session.inbox.enqueued", { |
| 1894 | inboxID: "msg_report", |
| 1895 | sessionID: SESSION, |
| 1896 | item: { |
| 1897 | type: "synthetic", |
| 1898 | payload: { |
| 1899 | text: `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`, |
| 1900 | description: "Sleep", |
| 1901 | metadata: { |
| 1902 | source: "subagent", |
| 1903 | childID: CHILD, |
| 1904 | agent: "General", |
| 1905 | state: "completed", |
| 1906 | }, |
| 1907 | }, |
| 1908 | delivery: "steer", |
| 1909 | }, |
| 1910 | }), |
| 1911 | event("session.execution.started", { sessionID: SESSION }), |
| 1912 | event( |
| 1913 | "session.text.ended", |
| 1914 | { |
| 1915 | sessionID: SESSION, |
| 1916 | assistantMessageID: "msg_followup", |
| 1917 | ordinal: 0, |
| 1918 | text: "CHILD_OK", |
| 1919 | }, |
| 1920 | "reply", |
| 1921 | ), |
| 1922 | event("session.execution.succeeded", { sessionID: SESSION }), |
| 1923 | // The subagent's session runs again: its turn marks that the reply's end was handled. |
| 1924 | event("session.execution.started", { sessionID: CHILD }), |
| 1925 | ], |
| 1926 | { replayGate: gate }, |
| 1927 | ).pipe( |
| 1928 | Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { |
| 1929 | offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), |
| 1930 | take: Effect.never, |
| 1931 | }), |
| 1932 | ); |
| 1933 | // The single reader of the runtime's events. |
| 1934 | const attached = yield* Deferred.make<void>(); |
| 1935 | const collected = yield* runtime.events.pipe( |
| 1936 | Stream.tap((event) => |
| 1937 | event.type === "subagent.updated" && event.subagent.childThreadId !== null |
| 1938 | ? Deferred.succeed(attached, undefined) |
| 1939 | : Effect.void, |
| 1940 | ), |
| 1941 | Stream.takeUntil( |
| 1942 | (event) => |
| 1943 | event.type === "provider_turn.updated" && |
| 1944 | event.providerTurn.nativeTurnRef?.nativeId?.startsWith(`${CHILD}:turn:`) === true, |
| 1945 | ), |
| 1946 | Stream.runCollect, |
| 1947 | Effect.forkScoped, |
| 1948 | ); |
| 1949 | yield* runtime.startTurn(withLineage(thread)); |
| 1950 | yield* Deferred.await(attached); |
| 1951 | const interrupt = yield* runtime |
| 1952 | .interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId }) |
| 1953 | .pipe(Effect.forkScoped); |
| 1954 | yield* TestClock.adjust("11 seconds"); |
| 1955 | yield* Fiber.join(interrupt); |
| 1956 | yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }); |
| 1957 | gate.release("drop"); |
| 1958 | yield* Deferred.await(offered); |
| 1959 | yield* runtime.startTurn({ |
| 1960 | ...withLineage(thread), |
| 1961 | runId: RunId.make("run:opencode2-adapter:wake"), |
| 1962 | runOrdinal: 3, |
| 1963 | 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 | }); |
| 1972 | gate.release("reply"); |
| 1973 | // The reply's own end finishes its continuation: the lost Stop's end does not take it. |
| 1974 | assert.deepEqual( |
| 1975 | [...(yield* Fiber.join(collected))].flatMap((event) => |
| 1976 | event.type === "turn.terminal" ? [event.status] : [], |
| 1977 | ), |
| 1978 | ["interrupted", "failed", "completed"], |
| 1979 | ); |
| 1980 | }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), |
| 1981 | ); |
| 1982 | |
| 1983 | /** |
| 1984 | * A prompt fails without a clear rejection, OpenCode never took it, and a |
| 1985 | * background subagent's report then wakes the parent into a reply. With |
| 1986 | * `endedFirst` that reply ends before its continuation takes it. |
| 1987 | */ |
| 1988 | const replyAfterUnclearFailure = (endedFirst: boolean) => |
| 1989 | Effect.gen(function* () { |
| 1990 | const offered = yield* Deferred.make<void>(); |
| 1991 | const gate = makeProviderReplayGate(endedFirst ? ["wake"] : ["wake", "reply"]); |
| 1992 | const { runtime, thread } = yield* resumed( |
| 1993 | [ |
| 1994 | ...backgroundLaunch(CHILD), |
| 1995 | event("session.execution.succeeded", { sessionID: SESSION }), |
| 1996 | out("session.update", { sessionID: CHILD, permissions: t3Rules }), |
| 1997 | reply("session.update", null), |
| 1998 | out("session.prompt", { sessionID: SESSION, text: "<any>" }), |
| 1999 | reply("session.prompt", { |
| 2000 | status: 502, |
| 2001 | body: { _tag: "UnknownError", message: "bad gateway" }, |
| 2002 | }), |
| 2003 | // The subagent ends and OpenCode answers its report on its own. |
| 2004 | event("session.execution.succeeded", { sessionID: CHILD }, "wake"), |
| 2005 | event("session.inbox.enqueued", { |
| 2006 | inboxID: "msg_report", |
| 2007 | sessionID: SESSION, |
| 2008 | item: { |
| 2009 | type: "synthetic", |
| 2010 | payload: { |
| 2011 | text: `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`, |
| 2012 | description: "Sleep", |
| 2013 | metadata: { |
| 2014 | source: "subagent", |
| 2015 | childID: CHILD, |
| 2016 | agent: "General", |
| 2017 | state: "completed", |
| 2018 | }, |
| 2019 | }, |
| 2020 | delivery: "steer", |
| 2021 | }, |
| 2022 | }), |
| 2023 | event("session.execution.started", { sessionID: SESSION }), |
| 2024 | event( |
| 2025 | "session.text.ended", |
| 2026 | { |
| 2027 | sessionID: SESSION, |
| 2028 | assistantMessageID: "msg_followup", |
| 2029 | ordinal: 0, |
| 2030 | text: "CHILD_OK", |
| 2031 | }, |
| 2032 | endedFirst ? undefined : "reply", |
| 2033 | ), |
| 2034 | event("session.execution.succeeded", { sessionID: SESSION }), |
| 2035 | // The subagent's session runs again: a turn of its own, after the reply's end. |
| 2036 | event("session.execution.started", { sessionID: CHILD }), |
| 2037 | ], |
| 2038 | { replayGate: gate }, |
| 2039 | ).pipe( |
| 2040 | Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { |
| 2041 | offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), |
| 2042 | take: Effect.never, |
| 2043 | }), |
| 2044 | ); |
| 2045 | // The single reader of the runtime's events. |
| 2046 | const launched = yield* Deferred.make<void>(); |
| 2047 | const childRan = yield* Deferred.make<void>(); |
| 2048 | const ended: Array<ProviderAdapter.ProviderAdapterV2Event> = []; |
| 2049 | const allEnded = yield* Deferred.make<void>(); |
| 2050 | yield* runtime.events.pipe( |
| 2051 | Stream.tap((event) => |
| 2052 | Effect.gen(function* () { |
| 2053 | if ( |
| 2054 | event.type === "provider_turn.updated" && |
| 2055 | event.providerTurn.nativeTurnRef?.nativeId?.startsWith(`${CHILD}:turn:`) === true |
| 2056 | ) { |
| 2057 | yield* Deferred.succeed(childRan, undefined); |
| 2058 | } |
| 2059 | if (event.type !== "turn_item.updated" && event.type !== "turn.terminal") return; |
| 2060 | ended.push(event); |
| 2061 | if (event.type === "turn.terminal") yield* Deferred.succeed(launched, undefined); |
| 2062 | if (ended.filter((entry) => entry.type === "turn.terminal").length === 3) { |
| 2063 | yield* Deferred.succeed(allEnded, undefined); |
| 2064 | } |
| 2065 | }), |
| 2066 | ), |
| 2067 | Stream.runDrain, |
| 2068 | Effect.forkScoped, |
| 2069 | ); |
| 2070 | yield* runtime.startTurn(withLineage(thread)); |
| 2071 | yield* Deferred.await(launched); |
| 2072 | yield* runtime |
| 2073 | .startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }) |
| 2074 | .pipe(Effect.ignore); |
| 2075 | gate.release("wake"); |
| 2076 | yield* Deferred.await(offered); |
| 2077 | // Everything up to the subagent's next run has been read: the reply ended. |
| 2078 | if (endedFirst) yield* Deferred.await(childRan); |
| 2079 | yield* runtime.startTurn({ |
| 2080 | ...withLineage(thread), |
| 2081 | runId: RunId.make("run:opencode2-adapter:wake"), |
| 2082 | runOrdinal: 3, |
| 2083 | 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 | }); |
| 2092 | if (!endedFirst) gate.release("reply"); |
| 2093 | // The reply's own end finishes its continuation: the failed prompt's mark does not take it. |
| 2094 | yield* Deferred.await(allEnded); |
| 2095 | assert.deepEqual( |
| 2096 | ended.flatMap((event) => (event.type === "turn.terminal" ? [event.status] : [])), |
| 2097 | ["completed", "failed", "completed"], |
| 2098 | ); |
| 2099 | assert.include( |
| 2100 | ended.flatMap((event) => |
| 2101 | event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" |
| 2102 | ? [event.turnItem.text] |
| 2103 | : [], |
| 2104 | ), |
| 2105 | "CHILD_OK", |
| 2106 | ); |
| 2107 | }).pipe(Effect.scoped, Effect.provide(TestClock.layer())); |
| 2108 | |
| 2109 | it.effect("ends a background reply's continuation after a prompt failed unclearly", () => |
| 2110 | replyAfterUnclearFailure(false), |
| 2111 | ); |
| 2112 | |
| 2113 | it.effect("ends a finished background reply's continuation after a prompt failed unclearly", () => |
| 2114 | replyAfterUnclearFailure(true), |
| 2115 | ); |
| 2116 | |
| 1767 | 2117 | it.effect("does not report a Stop the server says did nothing", () => |
| 1768 | 2118 | Effect.gen(function* () { |
| 1769 | 2119 | const { runtime, thread } = yield* resumed([ |