From 4da872d15be345f0edf15a34cb0c0e48a6525ba1 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Wed, 30 Sep 2026 13:49:34 -0400 Subject: [PATCH 01/34] fix(desktop): Group Linux windows under the installed launcher The running window's X11 class and Wayland app id come from setDesktopName("com.t3tools.T3Code.desktop"), but the AppImage, .deb and AUR launchers advertised StartupWMClass=t3code. GNOME, Cinnamon and similar shells then matched the window to the hidden URL-handler entry, showing a second dock icon that cannot be pinned. Advertise StartupWMClass=com.t3tools.T3Code in the electron-builder desktop entry and both AUR launchers. --- packaging/aur/t3code-bin/PKGBUILD | 2 +- packaging/aur/t3code-nightly-bin/PKGBUILD | 2 +- scripts/build-desktop-artifact.test.ts | 3 +++ scripts/build-desktop-artifact.ts | 4 +++- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packaging/aur/t3code-bin/PKGBUILD b/packaging/aur/t3code-bin/PKGBUILD index c5219666bf08..2efc7fdc00f7 100644 --- a/packaging/aur/t3code-bin/PKGBUILD +++ b/packaging/aur/t3code-bin/PKGBUILD @@ -94,7 +94,7 @@ TryExec=t3code Terminal=false Type=Application Icon=t3code -StartupWMClass=t3code +StartupWMClass=com.t3tools.T3Code Categories=Development; MimeType=x-scheme-handler/t3code; EOF diff --git a/packaging/aur/t3code-nightly-bin/PKGBUILD b/packaging/aur/t3code-nightly-bin/PKGBUILD index f3b61c7d5223..fbe560175e4f 100644 --- a/packaging/aur/t3code-nightly-bin/PKGBUILD +++ b/packaging/aur/t3code-nightly-bin/PKGBUILD @@ -95,7 +95,7 @@ TryExec=t3code-nightly Terminal=false Type=Application Icon=t3code-nightly -StartupWMClass=t3code +StartupWMClass=com.t3tools.T3Code Categories=Development; MimeType=x-scheme-handler/t3code; EOF diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 98ba0aaefbc8..816a2b48abc7 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -684,6 +684,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual((linux.linux as Record).protocols, [ { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, ]); + assert.deepStrictEqual((linux.linux as Record).desktop, { + entry: { StartupWMClass: "com.t3tools.T3Code" }, + }); assert.deepStrictEqual(mac.files, [...DESKTOP_FILE_EXCLUSIONS, ...MAC_FILE_EXCLUSIONS]); assert.deepStrictEqual(linux.files, [...DESKTOP_FILE_EXCLUSIONS, ...LINUX_FILE_EXCLUSIONS]); assert.deepStrictEqual(win.files, DESKTOP_FILE_EXCLUSIONS); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 5b22fddbb39a..f392866b1457 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2756,7 +2756,9 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( ], desktop: { entry: { - StartupWMClass: "t3code", + // Matches the app id from setDesktopName, so docks group the running + // window under this launcher rather than the hidden URL handler. + StartupWMClass: "com.t3tools.T3Code", }, }, }; From 40d09e96dba21316e16ff3de8a2ae2fb1ace8a4a Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Tue, 22 Sep 2026 12:08:40 -0400 Subject: [PATCH 02/34] fix(web): Copy sole fenced messages without delimiters --- .../chat/MessagesTimeline.logic.test.ts | 13 +++ .../components/chat/MessagesTimeline.logic.ts | 4 +- .../components/chat/MessagesTimeline.test.tsx | 103 ++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 9 +- apps/web/src/markdown-clipboard.test.ts | 44 +++++++- apps/web/src/markdown-clipboard.ts | 14 +++ 6 files changed, 182 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index d95e22517630..d84631fd9f4e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1073,6 +1073,19 @@ describe("resolveAssistantMessageCopyState", () => { }); }); + it("copies a sole fenced code block without the fences", () => { + expect( + resolveAssistantMessageCopyState({ + showCopyButton: true, + text: "```\nLorem ipsum dolor sit amet.\n```", + streaming: false, + }), + ).toEqual({ + text: "Lorem ipsum dolor sit amet.", + visible: true, + }); + }); + it("copies the rendered representation of Codex directives", () => { expect( resolveAssistantMessageCopyState({ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 21508a9663ae..c53d29a10fb8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -3,6 +3,7 @@ export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setu import * as Equal from "effect/Equal"; import { shallow } from "zustand/vanilla/shallow"; import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; +import { unwrapSoleMarkdownFence } from "../../markdown-clipboard"; import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; import { liveActivityToolStatus, @@ -521,8 +522,9 @@ export function resolveAssistantMessageCopyState({ }) { const hasText = text !== null && text.trim().length > 0; const visible = showCopyButton && hasText && !streaming; + const copyText = visible ? unwrapSoleMarkdownFence(renderCodexDirectivesForCopy(text)) : text; return { - text: hasText ? (visible ? renderCodexDirectivesForCopy(text) : text) : null, + text: hasText ? copyText : null, visible, }; } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index b6e5f2a2927e..279678415be1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,6 +1,7 @@ import { ApprovalRequestId, CheckpointRef, + ComposerContextId, EnvironmentId, MessageId, TurnId, @@ -11,6 +12,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react"; +import { COMPOSER_CONTEXT_CLIPBOARD_MIME } from "@t3tools/shared/composerContextClipboard"; import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; import { useComposerFocusState } from "./useComposerFocusState"; @@ -156,6 +158,9 @@ function stubDomGlobals() { contains: () => false, }; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => {}); vi.stubGlobal("Element", ElementStub); vi.stubGlobal("localStorage", { getItem: () => null, @@ -165,6 +170,7 @@ function stubDomGlobals() { }); vi.stubGlobal("window", { Element: ElementStub, + localStorage, matchMedia, addEventListener: () => {}, removeEventListener: () => {}, @@ -176,6 +182,7 @@ function stubDomGlobals() { desktopBridge: undefined, }); vi.stubGlobal("document", { + getElementById: () => ({}), documentElement: { classList, offsetHeight: 0, @@ -285,6 +292,102 @@ function buildSnapShotTimelineEntry(previewUrl?: string) { } describe("MessagesTimeline", () => { + it.each(["user", "assistant"] as const)( + "copies a sole fence through the actual %s Copy handler", + async (role) => { + const writeText = vi.fn(async () => {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + let renderer!: ReactTestRenderer; + try { + await act(async () => { + renderer = create( + , + ); + }); + await act(async () => { + renderer.root + .findAllByType("button") + .find((button) => button.props["aria-label"] === "Copy message")! + .props.onClick({ nativeEvent: new Event("click") }); + }); + expect(writeText).toHaveBeenCalledExactlyOnceWith("a\r\nb"); + } finally { + act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }, + ); + + it("keeps fenced canonical references and structured context in the actual Copy handler", async () => { + const writes: Array> = []; + class ClipboardItemStub { + constructor(readonly data: Record) {} + } + const writeText = vi.fn(async () => {}); + vi.stubGlobal("ClipboardItem", ClipboardItemStub); + vi.stubGlobal("navigator", { + clipboard: { + writeText, + write: async (items: ClipboardItemStub[]) => { + writes.push(...items.map((item) => item.data)); + }, + }, + }); + const text = "```md\n[notes.txt](t3-context://v1/file/file-1)\n```"; + const record = { + version: 1 as const, + contextId: ComposerContextId.make("file-1"), + kind: "file" as const, + label: "notes.txt", + attachmentId: "attachment-1", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 3, + }; + const entry = buildUserTimelineEntry(text); + let renderer!: ReactTestRenderer; + try { + await act(async () => { + renderer = create( + , + ); + }); + await act(async () => { + renderer.root + .findAllByType("button") + .find((button) => button.props["aria-label"] === "Copy message")! + .props.onClick({ nativeEvent: new Event("click") }); + }); + expect(writeText).not.toHaveBeenCalled(); + expect(writes).toHaveLength(1); + expect(await writes[0]!["text/plain"]!.text()).toBe(text); + expect(JSON.parse(await writes[0]![COMPOSER_CONTEXT_CLIPBOARD_MIME]!.text())).toMatchObject({ + version: 1, + source: { environmentId: ACTIVE_THREAD_ENVIRONMENT_ID, messageId: entry.message.id }, + records: [record], + }); + expect(await writes[0]!["text/html"]!.text()).toContain("t3-context://v1/file/file-1"); + } finally { + act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); + it("renders previous and next controls with the minimap", () => { const first = buildUserTimelineEntry("First turn"); const secondBase = buildUserTimelineEntry("Second turn"); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8ac769f33e7f..c3ad7d7799c4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -175,6 +175,7 @@ import { type AssistantCitationTarget, } from "./AssistantCitationSource"; import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget"; +import { unwrapSoleMarkdownFence } from "../../markdown-clipboard"; import { computeStableMessagesTimelineRows, deriveMessagesTimelineRowsWithState, @@ -2243,9 +2244,11 @@ function UserTimelineRow({ row }: { row: Extract reference.label, + : unwrapSoleMarkdownFence( + replaceComposerContextReferences( + resolvedContext.text, + (reference) => reference.label, + ), ) } {...(contextClipboardFragment diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index a08dc299161b..721b54094c44 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { serializeRenderedMarkdownFragment } from "./markdown-clipboard"; import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { collectAssistantCitations, serializeAssistantCitation, } from "@t3tools/shared/assistantCitations"; +import { serializeRenderedMarkdownFragment, unwrapSoleMarkdownFence } from "./markdown-clipboard"; const TEXT_NODE = 3; const ELEMENT_NODE = 1; @@ -343,3 +343,45 @@ describe("serializeRenderedMarkdownFragment", () => { ); }); }); + +describe("unwrapSoleMarkdownFence", () => { + it.each([ + ["```\n```", ""], + ["~~~sh\nprintf hello\n~~~~", "printf hello"], + ["```text\r\na\r\nb\r\n```\r\n", "a\r\nb"], + ["\n```\n\nbody\n\n```\n", "\nbody\n"], + ["````md\n```js\na()\n```\n````", "```js\na()\n```"], + ["```\na\n~~~\nb\n```", "a\n~~~\nb"], + ])("unwraps one valid fence: %j", (source, expected) => { + expect(unwrapSoleMarkdownFence(source)).toBe(expected); + }); + + it.each([ + "```js\na()\n```\n\n```js\nb()\n```", + "~~~\na\n~~~\ntext\n~~~", + "```\na\n```\ntrailing text", + "```\na\n``` trailing text", + "```\na\n~~~,", + "````\na\n```", + "```bad`info\na\n```", + " ```\na\n```", + "plain text", + ])("preserves messages that are not one complete block: %j", (source) => { + expect(unwrapSoleMarkdownFence(source)).toBe(source); + }); + + it("copies a message that is only a fenced code block without the fences", () => { + expect( + unwrapSoleMarkdownFence("```\nLorem ipsum dolor sit amet, consectetur adipiscing elit.\n```"), + ).toBe("Lorem ipsum dolor sit amet, consectetur adipiscing elit."); + }); + + it("strips an info string from a sole fenced block", () => { + expect(unwrapSoleMarkdownFence("```text\nprintf hello\n```")).toBe("printf hello"); + }); + + it("keeps fences when the message has prose around the code block", () => { + const markdown = "Run this:\n\n```\ngh workflow run Deploy\n```"; + expect(unwrapSoleMarkdownFence(markdown)).toBe(markdown); + }); +}); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 39b941801638..f5938d47e5f2 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -342,6 +342,20 @@ export function serializeRenderedMarkdownFragment(container: Node): string { return tidyMarkdown(serializeChildren(container)); } +/** Unwrap only a complete single code block, stopping at its first closing fence. */ +export function unwrapSoleMarkdownFence(markdown: string): string { + const source = markdown.replace(/^(?:[ \t]*\r?\n)+/, ""); + const opening = /^ {0,3}(`{3,}|~{3,})([^\r\n]*)\r?\n/.exec(source); + if (!opening) return markdown; + const fence = opening[1]!; + if (fence.startsWith("`") && opening[2]!.includes("`")) return markdown; + const closingPattern = new RegExp(`^ {0,3}${fence[0]}{${fence.length},}[ \\t]*\\r?$`, "gm"); + closingPattern.lastIndex = opening[0].length; + const closing = closingPattern.exec(source); + if (!closing || source.slice(closing.index + closing[0].length).trim()) return markdown; + return source.slice(opening[0].length, closing.index).replace(/\r?\n$/, ""); +} + export function serializeTableElementToMarkdown(table: Element): string { return serializeTable(table).trim(); } From 18ba005d1b6fbd0eaaa6c67de42cabb3b5a84676 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Tue, 22 Sep 2026 12:10:38 -0400 Subject: [PATCH 03/34] fix(mobile): Apply native header options only while focused --- apps/mobile/src/native/StackHeader.test.tsx | 91 +++++++++++++++++++++ apps/mobile/src/native/StackHeader.tsx | 22 ++++- 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/native/StackHeader.test.tsx diff --git a/apps/mobile/src/native/StackHeader.test.tsx b/apps/mobile/src/native/StackHeader.test.tsx new file mode 100644 index 000000000000..10e8afc0b6b3 --- /dev/null +++ b/apps/mobile/src/native/StackHeader.test.tsx @@ -0,0 +1,91 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const fixture = vi.hoisted(() => ({ + focused: false, + listeners: new Map void>>(), + setOptions: vi.fn(), +})); +vi.mock("@react-navigation/native", () => { + const navigation = { + isFocused: () => fixture.focused, + setOptions: fixture.setOptions, + addListener: (event: string, callback: () => void) => { + const listeners = fixture.listeners.get(event) ?? new Set(); + fixture.listeners.set(event, listeners); + listeners.add(callback); + return () => listeners.delete(callback); + }, + }; + return { useNavigation: () => navigation }; +}); +vi.mock("./NativeHeaderToolbar", () => ({ NativeHeaderToolbar: () => null })); +import { NativeStackScreenOptions } from "./StackHeader"; + +let root: Root; +beforeEach(() => { + fixture.focused = false; + fixture.listeners.clear(); + fixture.setOptions.mockClear(); + const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); +}); +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); +async function focus() { + fixture.focused = true; + await act(() => { + fixture.listeners.get("focus")?.forEach((listener) => listener()); + }); +} + +describe("focused native headers", () => { + it("defers hidden-screen changes until focus and reapplies on returning", async () => { + await act(() => root.render()); + expect(fixture.setOptions).not.toHaveBeenCalled(); + await focus(); + expect(fixture.setOptions).toHaveBeenLastCalledWith({ title: "Home" }); + await act(() => root.render()); + expect(fixture.setOptions).toHaveBeenCalledTimes(1); + fixture.focused = false; + await act(() => root.render()); + expect(fixture.setOptions).toHaveBeenCalledTimes(1); + await focus(); + expect(fixture.setOptions).toHaveBeenLastCalledWith({ title: "Updated" }); + fixture.focused = false; + await focus(); + expect(fixture.setOptions).toHaveBeenCalledTimes(3); + }); + + it("keeps caller blur cleanup and removes all listeners on unmount", async () => { + const blur = vi.fn(); + await act(() => + root.render(), + ); + fixture.listeners.get("blur")?.forEach((listener) => listener()); + expect(blur).toHaveBeenCalledOnce(); + await act(() => + root.render(), + ); + expect(fixture.listeners.get("focus")?.size).toBe(1); + await act(() => root.render(null)); + expect(fixture.listeners.get("focus")?.size).toBe(0); + expect(fixture.listeners.get("blur")?.size).toBe(0); + await focus(); + expect(fixture.setOptions).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 4f8eadcc5a11..e1db5a516003 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -157,18 +157,36 @@ export function NativeStackScreenOptions(props: { ) as NativeStackNavigationOptions) : undefined; - useLayoutEffect(() => { - if (!navigation || !stableOptions) { + const applyOptionsIfFocused = () => { + if (!navigation || !stableOptions || !navigation.isFocused()) { return; } const signature = optionsSignature([stableOptions, props.optionsVersion]); // Avoid re-entering navigation state when semantically equal options are // reapplied every layout (common when callers pass unstable object literals). + // Re-entering during a native push/pop is what produces + // "Unbalanced calls to begin/end appearance transitions" and a Home + // list that still paints but no longer receives taps. if (lastAppliedOptionsSignatureRef.current === signature) { return; } lastAppliedOptionsSignatureRef.current = signature; navigation.setOptions(stableOptions); + }; + + useLayoutEffect(() => { + applyOptionsIfFocused(); + }, [navigation, props.optionsVersion, stableOptions]); + + useEffect(() => { + if (!navigation) { + return; + } + const unsubscribe = navigation.addListener("focus", () => { + lastAppliedOptionsSignatureRef.current = undefined; + applyOptionsIfFocused(); + }); + return unsubscribe; }, [navigation, props.optionsVersion, stableOptions]); useEffect(() => { From 88929aa7afd912a87db94cb6ff0198a1c6063d81 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Tue, 22 Sep 2026 12:13:29 -0400 Subject: [PATCH 04/34] fix(mobile): Retry account loading without losing navigation --- apps/mobile/src/App.tsx | 8 +- .../src/features/cloud/CloudAuthProvider.tsx | 83 +++++++-- .../features/cloud/CloudAuthRecovery.test.tsx | 157 ++++++++++++++++++ .../features/cloud/clerkLoadRecovery.test.ts | 35 ++++ .../src/features/cloud/clerkLoadRecovery.ts | 13 ++ .../settings/SettingsAuthRouteScreen.tsx | 30 +++- .../features/settings/SettingsRouteScreen.tsx | 12 +- apps/mobile/src/navigationPersistence.test.ts | 34 ++++ apps/mobile/src/navigationPersistence.ts | 17 ++ 9 files changed, 364 insertions(+), 25 deletions(-) create mode 100644 apps/mobile/src/features/cloud/CloudAuthRecovery.test.tsx create mode 100644 apps/mobile/src/features/cloud/clerkLoadRecovery.test.ts create mode 100644 apps/mobile/src/features/cloud/clerkLoadRecovery.ts create mode 100644 apps/mobile/src/navigationPersistence.test.ts create mode 100644 apps/mobile/src/navigationPersistence.ts diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 34a742f8f11b..29a018f35452 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -17,6 +17,7 @@ import { AppearancePreferencesProvider, useAppearancePreferences, } from "./features/settings/appearance/AppearancePreferencesProvider"; +import { readRememberedNavigationState, rememberNavigationState } from "./navigationPersistence"; import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; import { OverlayPortalHost } from "./components/OverlayPortal"; @@ -87,7 +88,12 @@ function AppContent() { the system is in dark mode. */} - + diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index fffdd2343044..a3fe68210995 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -8,7 +8,16 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import * as Effect from "effect/Effect"; -import { type ReactNode, useEffect, useRef } from "react"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { runtime } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; @@ -50,18 +59,25 @@ export function activateCloudRelayAccount( }); } -function CloudAuthBridge(props: { readonly children: ReactNode }) { +type CloudRelayAccountProvider = { + readonly userId: string; + readonly provider: () => Promise; +}; + +function CloudAuthBridge(props: { + readonly children: ReactNode; + readonly previousTokenProviderRef: { current: CloudRelayAccountProvider | null }; + readonly observedAccountRef: { current: string | null | undefined }; + readonly accountTransitionRef: { current: Promise | null }; +}) { const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); const removeRelayEnvironments = useAtomCommand(removeCloudEnvironments, { reportFailure: false, reportDefect: false, }); - const previousTokenProviderRef = useRef<{ - readonly userId: string; - readonly provider: () => Promise; - } | null>(null); - const observedAccountRef = useRef(undefined); - const accountTransitionRef = useRef | null>(null); + const previousTokenProviderRef = props.previousTokenProviderRef; + const observedAccountRef = props.observedAccountRef; + const accountTransitionRef = props.accountTransitionRef; useEffect(() => { let cancelled = false; @@ -182,12 +198,10 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { useEffect( () => () => { - previousTokenProviderRef.current = null; - // Unmounting is not a sign-out: the user is usually still signed in, so - // detach the provider without ending lock-screen activities or wiping the - // persisted registration (a remount reuses both). + // Unmounting is not a sign-out. Keep the account-transition refs on the + // parent so a Clerk remount does not discard in-flight cleanup or look like + // a cold start. Sign-out still goes through deactivateCloudRelayAccount. releaseAgentAwarenessRelayTokenProvider(); - setManagedRelaySession(appAtomRegistry, null); }, [], ); @@ -195,10 +209,28 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { return props.children; } +export interface CloudAuthLoadState { + readonly remount: () => void; +} + +const idleCloudAuthLoadState: CloudAuthLoadState = { + remount: () => undefined, +}; + +const CloudAuthLoadContext = createContext(idleCloudAuthLoadState); + +export function useCloudAuthLoadState(): CloudAuthLoadState { + return useContext(CloudAuthLoadContext); +} + export function CloudAuthProvider(props: { readonly children: ReactNode }) { const config = resolveCloudPublicConfig(); const publishableKey = config.clerk.publishableKey; const relayUrl = config.relay.url; + const [generation, setGeneration] = useState(0); + const previousTokenProviderRef = useRef(null); + const observedAccountRef = useRef(undefined); + const accountTransitionRef = useRef | null>(null); useEffect(() => { if (!publishableKey || !relayUrl) { @@ -206,13 +238,32 @@ export function CloudAuthProvider(props: { readonly children: ReactNode }) { } }, [publishableKey, relayUrl]); + const remount = useCallback(() => { + setGeneration((current) => current + 1); + }, []); + + const loadState = useMemo( + () => ({ + remount, + }), + [remount], + ); + if (!publishableKey || !relayUrl) { return props.children; } return ( - - {props.children} - + + + + {props.children} + + + ); } diff --git a/apps/mobile/src/features/cloud/CloudAuthRecovery.test.tsx b/apps/mobile/src/features/cloud/CloudAuthRecovery.test.tsx new file mode 100644 index 000000000000..6dd2136b35d7 --- /dev/null +++ b/apps/mobile/src/features/cloud/CloudAuthRecovery.test.tsx @@ -0,0 +1,157 @@ +import { act, useEffect, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import * as Exit from "effect/Exit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const fixture = vi.hoisted(() => ({ + auth: { isLoaded: true, isSignedIn: true, userId: "alice", getToken: async () => "first-token" }, + mounts: 0, + release: vi.fn(), + session: vi.fn(), + awareness: vi.fn(), + remove: vi.fn(async (_account: string | null) => ({ + _tag: "Success" as const, + value: undefined, + })), + onboarding: vi.fn(), +})); +vi.mock("@clerk/expo", () => ({ + ClerkProvider: ({ children }: { children: ReactNode }) => { + useEffect(() => { + fixture.mounts++; + }, []); + return children; + }, + useAuth: () => fixture.auth, +})); +vi.mock("@clerk/expo/token-cache", () => ({ tokenCache: {} })); +vi.mock("@t3tools/client-runtime/relay", async (importOriginal) => ({ + ...(await importOriginal()), + setManagedRelaySession: fixture.session, +})); +vi.mock("../../lib/runtime", () => ({ + runtime: { runPromiseExit: async () => Exit.succeed(undefined) }, +})); +vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => fixture.remove })); +vi.mock("./cloud-drafts", () => ({ removeCloudEnvironments: {} })); +vi.mock("../../state/use-composer-drafts", () => ({ + getComposerCloudAccountId: async () => null, + restoreCloudComposerDrafts: async () => undefined, +})); +vi.mock("./connectOnboarding", () => ({ + clearConnectOnboardingRequest: vi.fn(), + requestConnectOnboarding: fixture.onboarding, +})); +vi.mock("./publicConfig", () => ({ + resolveCloudPublicConfig: () => ({ + clerk: { publishableKey: "test-key" }, + relay: { url: "https://relay.test" }, + }), + resolveRelayClerkTokenOptions: () => ({}), +})); +vi.mock("../agent-awareness/remoteRegistration", () => ({ + releaseAgentAwarenessRelayTokenProvider: fixture.release, + setAgentAwarenessRelayTokenProvider: fixture.awareness, + unregisterAgentAwarenessDeviceForCurrentUser: vi.fn(), +})); +import { CloudAuthProvider, useCloudAuthLoadState } from "./CloudAuthProvider"; + +let root: Root; +let remount: () => void; +function Probe() { + remount = useCloudAuthLoadState().remount; + return null; +} +async function render() { + await act(async () => { + root.render( + + + , + ); + }); +} +beforeEach(() => { + vi.clearAllMocks(); + fixture.mounts = 0; + fixture.auth = { + isLoaded: true, + isSignedIn: true, + userId: "alice", + getToken: async () => "first-token", + }; + const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); +}); +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); + +describe("manual Clerk recovery", () => { + it("retries loading and rebinds the same account without signing out or removing environments", async () => { + await render(); + expect(fixture.session).toHaveBeenLastCalledWith( + {}, + { accountId: "alice", readClerkToken: expect.any(Function) }, + ); + fixture.session.mockClear(); + fixture.auth = { ...fixture.auth, isLoaded: false }; + await act(async () => remount()); + expect(fixture.mounts).toBe(2); + expect(fixture.release).toHaveBeenCalledOnce(); + expect(fixture.session).not.toHaveBeenCalled(); + expect(fixture.remove).not.toHaveBeenCalled(); + fixture.auth = { ...fixture.auth, isLoaded: true, getToken: async () => "fresh-token" }; + await render(); + const session = fixture.session.mock.lastCall?.[1]; + expect(session.accountId).toBe("alice"); + expect(await session.readClerkToken()).toBe("fresh-token"); + expect(fixture.onboarding).not.toHaveBeenCalled(); + }); + + it("preserves a pending account cleanup across retry before activating a different account", async () => { + await render(); + let finish!: () => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + fixture.remove.mockImplementationOnce(async () => { + await pending; + return { _tag: "Success", value: undefined }; + }); + fixture.auth = { ...fixture.auth, userId: "bob" }; + await render(); + expect(fixture.remove).toHaveBeenCalledExactlyOnceWith("alice"); + expect(fixture.session).toHaveBeenLastCalledWith({}, null); + await act(async () => remount()); + expect(fixture.session).toHaveBeenLastCalledWith({}, null); + await act(async () => finish()); + expect(fixture.remove).toHaveBeenCalledTimes(1); + expect(fixture.session).toHaveBeenLastCalledWith( + {}, + { accountId: "bob", readClerkToken: expect.any(Function) }, + ); + }); + + it("still clears the session and removes the old account on sign-out", async () => { + await render(); + fixture.auth = { ...fixture.auth, isSignedIn: false }; + await render(); + expect(fixture.session).toHaveBeenLastCalledWith({}, null); + expect(fixture.remove).toHaveBeenCalledExactlyOnceWith("alice"); + expect(fixture.awareness).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/apps/mobile/src/features/cloud/clerkLoadRecovery.test.ts b/apps/mobile/src/features/cloud/clerkLoadRecovery.test.ts new file mode 100644 index 000000000000..18f67abac68f --- /dev/null +++ b/apps/mobile/src/features/cloud/clerkLoadRecovery.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { clerkAccountRowLabel } from "./clerkLoadRecovery"; + +describe("clerkAccountRowLabel", () => { + it("shows Checking until Clerk loads", () => { + expect( + clerkAccountRowLabel({ + email: undefined, + isLoaded: false, + isSignedIn: false, + }), + ).toBe("Checking"); + }); + + it("shows Sign in when loaded and signed out", () => { + expect( + clerkAccountRowLabel({ + email: undefined, + isLoaded: true, + isSignedIn: false, + }), + ).toBe("Sign in"); + }); + + it("prefers the account email when signed in", () => { + expect( + clerkAccountRowLabel({ + email: "c@mwolson.org", + isLoaded: true, + isSignedIn: true, + }), + ).toBe("c@mwolson.org"); + }); +}); diff --git a/apps/mobile/src/features/cloud/clerkLoadRecovery.ts b/apps/mobile/src/features/cloud/clerkLoadRecovery.ts new file mode 100644 index 000000000000..7823033bd5b2 --- /dev/null +++ b/apps/mobile/src/features/cloud/clerkLoadRecovery.ts @@ -0,0 +1,13 @@ +export function clerkAccountRowLabel(input: { + readonly email: string | undefined; + readonly isLoaded: boolean; + readonly isSignedIn: boolean; +}): string { + if (!input.isLoaded) { + return "Checking"; + } + if (!input.isSignedIn) { + return "Sign in"; + } + return input.email ?? "Signed in"; +} diff --git a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx index e6e23fd78be9..5d7869fd82c0 100644 --- a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx @@ -2,8 +2,10 @@ import { useAuth } from "@clerk/expo"; import { AuthView, type UserProfileCustomPage, UserProfileView } from "@clerk/expo/native"; import { StackActions, useNavigation } from "@react-navigation/native"; import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; -import { View } from "react-native"; +import { ActivityIndicator, Pressable, View } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { useCloudAuthLoadState } from "../cloud/CloudAuthProvider"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { T3ConnectProfilePage } from "../cloud/T3ConnectProfilePage"; @@ -31,6 +33,7 @@ export function SettingsAuthRouteScreen() { function ConfiguredSettingsAuthRouteScreen() { const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { remount } = useCloudAuthLoadState(); const navigation = useNavigation(); const handleHostBack = useCallback( () => navigation.dispatch(StackActions.popTo("SettingsContent")), @@ -59,7 +62,30 @@ function ConfiguredSettingsAuthRouteScreen() { ) : ( ) - ) : null} + ) : ( + + )} + + ); +} + +function ClerkLoadPendingView(props: { readonly onRetry: () => void }) { + return ( + + + + Checking T3 Account + + + T3 Account is still loading. Retry without clearing app data. + + + Retry + ); } diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 1b2aeb7cc891..33ff918c7996 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -5,6 +5,7 @@ import { Platform, View } from "react-native"; import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { clerkAccountRowLabel } from "../cloud/clerkLoadRecovery"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { NativeHeaderToolbar } from "../../native/StackHeader"; @@ -56,11 +57,11 @@ function ConfiguredSettingsRouteScreen() { const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); const { savedConnectionsById } = useSavedRemoteConnections(); - const accountLabel = !isLoaded - ? "Checking" - : !isSignedIn - ? "Sign in" - : (user?.primaryEmailAddress?.emailAddress ?? "Signed in"); + const accountLabel = clerkAccountRowLabel({ + email: user?.primaryEmailAddress?.emailAddress, + isLoaded, + isSignedIn: Boolean(isSignedIn), + }); return ( @@ -76,7 +77,6 @@ function ConfiguredSettingsRouteScreen() { icon="person.crop.circle" label="T3 Account" value={accountLabel} - disabled={!isLoaded} onPress={() => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" })} /> { + it("keeps the last stack across a remount", () => { + resetRememberedNavigationStateForTests(); + rememberNavigationState({ + index: 1, + key: "stack", + routeNames: ["Home", "Settings"], + routes: [ + { key: "home", name: "Home" }, + { key: "settings", name: "Settings" }, + ], + stale: false, + type: "stack", + }); + + expect(readRememberedNavigationState()).toMatchObject({ + index: 1, + routes: [{ name: "Home" }, { name: "Settings" }], + }); + + // An empty callback during navigator teardown must not discard the last stack. + const beforeTeardown = readRememberedNavigationState(); + rememberNavigationState(undefined); + expect(readRememberedNavigationState()).toBe(beforeTeardown); + }); +}); diff --git a/apps/mobile/src/navigationPersistence.ts b/apps/mobile/src/navigationPersistence.ts new file mode 100644 index 000000000000..e0c16bd7c8cd --- /dev/null +++ b/apps/mobile/src/navigationPersistence.ts @@ -0,0 +1,17 @@ +import type { NavigationState, PartialState } from "@react-navigation/native"; + +type RememberedNavigationState = NavigationState | PartialState; + +let rememberedNavigationState: RememberedNavigationState | undefined; + +export function rememberNavigationState(state: Readonly | undefined): void { + if (state !== undefined) rememberedNavigationState = state; +} + +export function readRememberedNavigationState(): RememberedNavigationState | undefined { + return rememberedNavigationState; +} + +export function resetRememberedNavigationStateForTests(): void { + rememberedNavigationState = undefined; +} From 007f9b4318282507fcaaee2d38575ad0d9cae6e1 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Tue, 22 Sep 2026 12:16:14 -0400 Subject: [PATCH 05/34] fix(mobile): Keep custom answers owned by the displayed request --- .../use-selected-thread-requests.test.tsx | 109 +++++++++++++++--- .../src/state/use-selected-thread-requests.ts | 26 ++++- 2 files changed, 113 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/state/use-selected-thread-requests.test.tsx b/apps/mobile/src/state/use-selected-thread-requests.test.tsx index 16dea6278358..0af2a6a746d7 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.test.tsx +++ b/apps/mobile/src/state/use-selected-thread-requests.test.tsx @@ -1,3 +1,5 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -6,8 +8,12 @@ const fixture = vi.hoisted(() => ({ uploads: {} as Record, preparations: {} as Record, preparationAtom: Symbol("preparation"), + requestIds: ["request-1"], + selectedThread: { environmentId: "environment-1", id: "thread-1" }, + set: vi.fn(), })); vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); +vi.mock("./atom-registry", () => ({ appAtomRegistry: { get: () => ({}), set: fixture.set } })); vi.mock("@effect/atom-react", () => ({ useAtomValue: (atom: unknown) => atom === "drafts" @@ -51,28 +57,26 @@ vi.mock("./threads", () => ({ threadEnvironment: {} })); vi.mock("./use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); vi.mock("./use-thread-selection", () => ({ useThreadSelection: () => ({ - selectedThread: { environmentId: "environment-1", id: "thread-1" }, + selectedThread: fixture.selectedThread, }), })); vi.mock("./use-thread-detail", () => ({ useSelectedThreadDetail: () => ({ - activities: [ - { - id: "request-activity", - kind: "user-input.requested", - createdAt: "2026-09-08T00:00:00Z", - payload: { - requestId: "request-1", - questions: ["first", "second"].map((id) => ({ - id, - header: id, - question: `Attach ${id} file`, - options: [], - allowCustomAnswer: true, - })), - }, + activities: fixture.requestIds.map((requestId) => ({ + id: `activity-${requestId}`, + kind: "user-input.requested", + createdAt: "2026-09-08T00:00:00Z", + payload: { + requestId, + questions: ["first", "second"].map((id) => ({ + id, + header: id, + question: `Attach ${id} file`, + options: [], + allowCustomAnswer: true, + })), }, - ], + })), }), })); @@ -96,6 +100,9 @@ function submitButtonMarkup() { return renderToStaticMarkup(); } beforeEach(() => { + fixture.requestIds = ["request-1"]; + fixture.selectedThread = { environmentId: "environment-1", id: "thread-1" }; + fixture.set.mockClear(); fixture.preparations = {}; fixture.drafts = Object.fromEntries( ["first", "second"].map((id) => [ @@ -116,6 +123,74 @@ beforeEach(() => { ); fixture.uploads = { "environment-1:first": { status: "ready" } }; }); +describe("question draft ownership", () => { + it.each(["id", "environmentId"] as const)( + "rejects stale request, %s and unmounted callbacks", + async (scope) => { + fixture.drafts = {}; + fixture.requestIds = ["request-1", "request-2"]; + let update!: ReturnType["onChangeUserInputCustomAnswer"]; + function Probe() { + update = useSelectedThreadRequests().onChangeUserInputCustomAnswer; + return null; + } + const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const root = createRoot(container as unknown as HTMLElement); + try { + await act(() => root.render()); + update(ApprovalRequestId.make("request-2"), "first", "hidden"); + update(ApprovalRequestId.make("missing"), "first", "missing"); + update(ApprovalRequestId.make("request-1"), "missing", "unknown question"); + expect(fixture.set).not.toHaveBeenCalled(); + update(ApprovalRequestId.make("request-1"), "first", "current"); + expect(fixture.set).toHaveBeenCalledOnce(); + const retained = update; + fixture.set.mockClear(); + fixture.requestIds = ["request-2", "request-1"]; + await act(() => root.render()); + retained(ApprovalRequestId.make("request-1"), "first", "delayed"); + update(ApprovalRequestId.make("request-1"), "first", "hidden"); + expect(fixture.set).not.toHaveBeenCalled(); + update(ApprovalRequestId.make("request-2"), "first", "new current"); + expect(fixture.set).toHaveBeenCalledOnce(); + fixture.set.mockClear(); + fixture.requestIds = []; + await act(() => root.render()); + retained(ApprovalRequestId.make("request-1"), "first", "removed"); + expect(fixture.set).not.toHaveBeenCalled(); + fixture.requestIds = ["request-1"]; + await act(() => root.render()); + const beforeSwitch = update; + fixture.selectedThread = { ...fixture.selectedThread, [scope]: "other-scope" }; + await act(() => root.render()); + beforeSwitch(ApprovalRequestId.make("request-1"), "first", "old scope"); + expect(fixture.set).not.toHaveBeenCalled(); + update(ApprovalRequestId.make("request-1"), "first", "current scope"); + expect(fixture.set).toHaveBeenCalledOnce(); + fixture.set.mockClear(); + const beforeUnmount = update; + await act(() => root.render(null)); + beforeUnmount(ApprovalRequestId.make("request-1"), "first", "unmounted"); + expect(fixture.set).not.toHaveBeenCalled(); + } finally { + await act(() => root.unmount()); + vi.unstubAllGlobals(); + } + }, + ); +}); + describe("question attachment submission readiness", () => { it.each([ undefined, diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 3e0cbe295936..6139090656b2 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -13,7 +13,7 @@ import { composerAttachmentsStillUploading, } from "./composer-attachment-uploads"; import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { ApprovalRequestId, @@ -105,6 +105,16 @@ export function useSelectedThreadRequests() { ); const activePendingApproval = activePendingApprovals[0] ?? null; const activePendingUserInput = activePendingUserInputs[0] ?? null; + const displayedRequestRef = useRef<{ + request: typeof activePendingUserInput; + thread: typeof selectedThreadShell; + } | null>(null); + useLayoutEffect(() => { + displayedRequestRef.current = { request: activePendingUserInput, thread: selectedThreadShell }; + return () => { + displayedRequestRef.current = null; + }; + }, [activePendingUserInput, selectedThreadShell]); const questionServerConfigs = useServerConfigs(); const attachmentDrafts = useAtomValue(composerDraftsAtom); const preparationCounts = useAtomValue(questionAttachmentPreparationAtom); @@ -199,9 +209,15 @@ export function useSelectedThreadRequests() { const onChangeUserInputCustomAnswer = useCallback( (requestId: ApprovalRequestId, questionId: string, customAnswer: string) => { - const question = activePendingUserInputs - .find((request) => request.requestId === requestId) - ?.questions.find((entry) => entry.id === questionId); + const displayed = displayedRequestRef.current; + if ( + !displayed?.request || + displayed.request.requestId !== requestId || + displayed.thread?.id !== selectedThreadShell?.id || + displayed.thread?.environmentId !== selectedThreadShell?.environmentId + ) + return; + const question = displayed.request.questions.find((entry) => entry.id === questionId); if (!selectedThreadShell || !question) { return; } @@ -209,7 +225,7 @@ export function useSelectedThreadRequests() { const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); setUserInputDraftCustomAnswer(requestKey, question, customAnswer); }, - [activePendingUserInputs, selectedThreadShell], + [selectedThreadShell], ); const onRespondToApproval = useCallback( From 8197f209965e557359f432f93311eade2b35b264 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Tue, 22 Sep 2026 12:20:16 -0400 Subject: [PATCH 06/34] fix(clients): Prefer last-used models over project defaults --- .../features/threads/NewTaskDraftScreen.tsx | 2 +- .../threads/new-task-flow-provider.tsx | 8 +- apps/mobile/src/lib/modelOptions.test.ts | 5 +- apps/mobile/src/lib/modelOptions.ts | 2 +- apps/mobile/src/state/use-composer-drafts.ts | 2 +- .../chat/DraftHeroHeadline.test.tsx | 217 +++++++ .../src/components/chat/DraftHeroHeadline.tsx | 20 +- apps/web/src/hooks/useHandleNewThread.test.ts | 532 ++++++++++-------- apps/web/src/hooks/useHandleNewThread.ts | 26 +- apps/web/src/lib/chatThreadActions.test.ts | 19 +- apps/web/src/lib/chatThreadActions.ts | 6 +- 11 files changed, 584 insertions(+), 255 deletions(-) create mode 100644 apps/web/src/components/chat/DraftHeroHeadline.test.tsx diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 7e90b0743374..a190cf8fc3a0 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1304,7 +1304,7 @@ export function NewTaskDraftScreen(props: { flow.finishEditingPendingTask(); } else { // Drop draft-local model/workspace selections with the content. The - // next task re-resolves project defaults before sticky app defaults. + // next task re-resolves sticky app defaults before project defaults. // The queued message owns the attachments now, so the sweep is deferred // until the write confirms it. clearComposerDraftContent(draftKey, { diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 61b77afffba1..0130b2f6b525 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -495,18 +495,18 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { () => buildModelOptions( selectedEnvironmentServerConfig, - draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection, + draftModelSelection ?? stickyModelSelection ?? projectDefaultModelSelection, ), [ selectedEnvironmentServerConfig, draftModelSelection, - projectDefaultModelSelection, stickyModelSelection, + projectDefaultModelSelection, ], ); - // An unsent draft keeps its explicit pick. Fresh drafts resolve the project - // default before the last manual app-wide selection and provider default. + // An unsent draft keeps its explicit pick. Fresh drafts resolve the + // last manual app-wide selection before the project and provider defaults. const selectedModel = resolveNewTaskModelSelection({ draftSelection: draftModelSelection, projectDefaultSelection: projectDefaultModelSelection, diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 98896b990ec4..ee0f13ccf386 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -373,7 +373,7 @@ describe("mobile model options", () => { expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy); }); - it("resolves new tasks from draft, project, sticky, then provider defaults", () => { + it("resolves new tasks from draft, sticky, project, then provider defaults", () => { const draft = { instanceId: ProviderInstanceId.make("codex"), model: "draft" }; const project = { instanceId: ProviderInstanceId.make("codex"), model: "project" }; const sticky = { instanceId: ProviderInstanceId.make("codex"), model: "sticky" }; @@ -394,7 +394,8 @@ describe("mobile model options", () => { }); expect(resolve(draft, project, sticky)).toBe(draft); - expect(resolve(null, project, sticky)).toBe(project); + expect(resolve(null, project, sticky)).toBe(sticky); + expect(resolve(null, project, null)).toBe(project); expect(resolve(null, null, sticky)).toBe(sticky); expect(resolve(null, null, null)).toBe(providerDefault.selection); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 4e8295733141..d5af009bdfc9 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -139,8 +139,8 @@ export function resolveNewTaskModelSelection(input: { }): ModelSelection | null { return ( input.draftSelection ?? - input.projectDefaultSelection ?? input.stickySelection ?? + input.projectDefaultSelection ?? input.modelOptions.find((option) => option.isDefault && !option.isUnavailable)?.selection ?? input.modelOptions.find((option) => !option.isUnavailable)?.selection ?? null diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index de886d54fe91..218c86ab76d6 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -621,7 +621,7 @@ export function decodePersistedComposerState(value: unknown): { // Stale new-task drafts left on disk by builds before the // model-precedence fix carry a bare modelSelection with no // other selector settings. Strip it so the next compose pass - // re-resolves project → sticky → provider defaults. Drafts + // re-resolves sticky → project → provider defaults. Drafts // with runtime/interaction/workspace settings or actual text / // attachments were deliberately configured and are left alone. isNewTaskDraftKey(key) && diff --git a/apps/web/src/components/chat/DraftHeroHeadline.test.tsx b/apps/web/src/components/chat/DraftHeroHeadline.test.tsx new file mode 100644 index 000000000000..3352735f4be3 --- /dev/null +++ b/apps/web/src/components/chat/DraftHeroHeadline.test.tsx @@ -0,0 +1,217 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + ProviderInstanceId, + type ClientSettings, + type ServerSettings, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import { act, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import type { Project } from "~/types"; + +const fixtures = vi.hoisted(() => ({ + projects: [] as Project[], + environments: [] as { + environmentId: EnvironmentId; + label: string; + serverConfig: { settings: ServerSettings; environment: { platform: "linux" } } | null; + }[], +})); +vi.mock("~/state/entities", () => ({ + useProjects: () => fixtures.projects, + useThreadShells: () => [], +})); +vi.mock("~/state/environments", () => ({ + useEnvironments: () => ({ environments: fixtures.environments }), + usePrimaryEnvironmentId: () => fixtures.projects[0]?.environmentId ?? null, +})); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: (select: (settings: ClientSettings) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), +})); +// Only replace presentation primitives, leaving the project handler and draft store real. +vi.mock("../ui/menu", () => { + const Container = ({ children }: { children?: ReactNode }) => <>{children}; + return { + Menu: Container, + MenuItem: Container, + MenuPopup: Container, + MenuRadioGroup: ({ children }: { children?: ReactNode }) => <>{children}, + MenuRadioItem: ({ children }: { children?: ReactNode }) => <>{children}, + MenuSeparator: () => null, + MenuTrigger: Container, + }; +}); +vi.mock("../ui/tooltip", () => ({ + Tooltip: ({ children }: { children?: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children}, + TooltipPopup: () => null, +})); +vi.mock("../ProjectFavicon", () => ({ ProjectFavicon: () => null })); +vi.mock("../ProjectEnvironmentBadge", () => ({ ProjectEnvironmentBadge: () => null })); + +import { MenuRadioGroup, MenuRadioItem } from "../ui/menu"; +import { DraftHeroHeadline } from "./DraftHeroHeadline"; + +const sourceEnvironment = EnvironmentId.make("source-environment"); +const targetEnvironment = EnvironmentId.make("target-environment"); +const draftId = DraftId.make("open-draft"); +const instance = ProviderInstanceId.make("codex"); +const previousSeed = createModelSelection(instance, "old-project-model"); +const projectSelection = createModelSelection(instance, "project-model"); +const stickySelection = createModelSelection(instance, "last-used-model", [ + { id: "reasoningEffort", value: "high" }, +]); +const explicitSelection = createModelSelection(instance, "explicit-model"); +let renderer: ReactTestRenderer | undefined; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + stickyActiveProvider: null, + stickyModelSelectionByProvider: {}, + }); + fixtures.projects = [ + makeProject("source", sourceEnvironment), + makeProject("target", targetEnvironment), + ]; + fixtures.environments = [ + { environmentId: sourceEnvironment, label: "Source", serverConfig: null }, + { environmentId: targetEnvironment, label: "Target", serverConfig: null }, + ]; + const store = useComposerDraftStore.getState(); + store.setLogicalProjectDraftThreadId( + "source-key", + scopeProjectRef(sourceEnvironment, fixtures.projects[0]!.id), + draftId, + ); + store.setPrompt(draftId, "Keep this prompt while switching projects"); + store.setModelSelection(draftId, previousSeed); +}); + +afterEach(() => { + act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("draft hero project switching", () => { + it("uses the current sticky choice over the destination project default", () => { + mount(); + // A choice made after render must win over both the old implicit seed and project pin. + useComposerDraftStore.getState().setStickyModelSelection(stickySelection); + chooseTarget(); + expectSelection(stickySelection); + expect( + useComposerDraftStore.getState().getComposerDraft(draftId)?.modelSelectionExplicit, + ).toBeUndefined(); + expectContinuity(); + }); + + it("falls back to the project default without mistaking the old implicit seed for sticky", () => { + mount(); + chooseTarget(); + expectSelection(projectSelection); + expect(useComposerDraftStore.getState().stickyActiveProvider).toBeNull(); + expectContinuity(); + }); + + it("retains an explicit draft choice despite sticky and project defaults", () => { + const store = useComposerDraftStore.getState(); + store.setModelSelection(draftId, explicitSelection, { explicit: true }); + store.setStickyModelSelection(stickySelection); + mount(); + chooseTarget(); + expectSelection(explicitSelection); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.modelSelectionExplicit).toBe( + true, + ); + expectContinuity(); + }); + + it.each(["project override", "environment fallback"] as const)( + "resolves the destination's %s when sticky is absent", + (source) => { + const resolvedSelection = createModelSelection(instance, "resolved-model"); + const target = fixtures.projects[1]!; + fixtures.environments[1]!.serverConfig = { + environment: { platform: "linux" }, + settings: { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsFolded: true, + defaultModelSelection: resolvedSelection, + projectSettingsOverrides: + source === "project override" + ? { [target.id]: { defaultModelSelection: projectSelection } } + : {}, + }, + }; + mount(); + chooseTarget(); + expectSelection(source === "project override" ? projectSelection : resolvedSelection); + expectContinuity(); + }, + ); +}); + +function mount() { + act(() => { + renderer = create( + , + ); + }); +} + +function chooseTarget() { + const picker = renderer!.root.findByType(MenuRadioGroup); + const target = renderer!.root.findAllByType(MenuRadioItem)[1]!; + act(() => picker.props.onValueChange(target.props.value)); +} + +function expectSelection(expected: ReturnType) { + const draft = useComposerDraftStore.getState().getComposerDraft(draftId); + expect(draft).not.toBeNull(); + expect(draft!.activeProvider).toBe(expected.instanceId); + expect(draft!.modelSelectionByProvider[expected.instanceId]).toEqual(expected); +} + +function expectContinuity() { + const store = useComposerDraftStore.getState(); + expect(store.getComposerDraft(draftId)?.prompt).toBe("Keep this prompt while switching projects"); + expect(store.getDraftSession(draftId)).toMatchObject({ + environmentId: targetEnvironment, + projectId: fixtures.projects[1]!.id, + }); + expect(Object.keys(store.draftsByThreadKey)).toEqual([draftId]); + expect(Object.keys(store.draftThreadsByThreadKey)).toEqual([draftId]); + expect(Object.values(store.logicalProjectDraftThreadKeyByLogicalProjectKey)).toEqual([draftId]); + expect(store.logicalProjectDraftThreadKeyByLogicalProjectKey["source-key"]).toBeUndefined(); +} + +function makeProject(title: string, environmentId: EnvironmentId): Project { + return { + id: ProjectId.make(title), + environmentId, + title, + workspaceRoot: `/work/${title}`, + repositoryIdentity: null, + defaultModelSelection: projectSelection, + createdAt: "2026-09-22T00:00:00.000Z", + updatedAt: "2026-09-22T00:00:00.000Z", + scripts: [], + }; +} diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index ea4741b80664..a6c342e44cf2 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -7,7 +7,10 @@ import { useCallback, useMemo } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; import { useClientSettings } from "~/hooks/useSettings"; -import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; +import { + hasExplicitComposerModelSelection, + resolveNewThreadModelSelectionOverride, +} from "~/lib/chatThreadActions"; import { selectProjectGroupingSettings } from "~/logicalProject"; import { buildSidebarProjectPickerEntries, @@ -183,8 +186,19 @@ export function DraftHeroHeadline({ ? resolveProjectSettings(environmentSettings, project.id, project).settings .defaultModelSelection : project.defaultModelSelection; - if (defaultModelSelection) { - setModelSelection(draftId, defaultModelSelection, { + const { stickyActiveProvider, stickyModelSelectionByProvider } = + useComposerDraftStore.getState(); + const modelSelection = resolveNewThreadModelSelectionOverride({ + projectDefaultSelection: defaultModelSelection ?? null, + stickySelection: stickyActiveProvider + ? (stickyModelSelectionByProvider[stickyActiveProvider] ?? null) + : null, + carrySelection: null, + carrySourceDraftId: draftId, + destinationDraftId: draftId, + }); + if (modelSelection) { + setModelSelection(draftId, modelSelection, { replaceOptions: true, }); } diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 3a29155bda6d..0b5439d1a192 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -1,289 +1,363 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import type { RuntimeMode } from "@t3tools/contracts"; +import { act, createElement } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type ServerSettings, + type T3ProjectFile, +} from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { createModelSelection } from "@t3tools/shared/model"; +import { DraftId, useComposerDraftStore } from "../composerDraftStore"; +import { + deriveLogicalProjectKeyFromSettings, + selectProjectGroupingSettings, +} from "../logicalProject"; +import type { Project } from "../types"; const testState = vi.hoisted(() => { - let completeProjectFileRead: (value: null) => void = () => undefined; - let projectFileRead = Promise.resolve(null); - let targetSettings = { - defaultThreadEnvMode: "local" as "local" | "worktree", - newWorktreesStartFromOrigin: false, - defaultModelSelection: null, - defaultRuntimeMode: "full-access" as RuntimeMode, - }; - let storedDraft: { - readonly draftId: string; - readonly environmentId: string; - readonly promotedTo: null; - readonly threadId: string; - } | null = null; const router = { state: { location: { href: "/" }, - matches: [{ params: {} }], + matches: [ + { params: {} as Partial> }, + ], }, - navigate: vi.fn(async (request: { readonly params: { readonly draftId: string } }) => { + navigate: vi.fn(async (request: { params: { draftId: string } }) => { router.state.location.href = `/draft/${request.params.draftId}`; }), }; - const draftStore = { - getComposerDraft: vi.fn(() => ({})), - getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft), - getDraftSession: vi.fn(() => null), - getDraftThread: vi.fn(() => null), - applyStickyState: vi.fn(), - setDraftThreadContext: vi.fn(), - setLogicalProjectDraftThreadId: vi.fn(), - setModelSelection: vi.fn(), - }; - return { - completeProjectFileRead: (value: null) => completeProjectFileRead(value), - draftStore, - get projectFileRead() { - return projectFileRead; - }, - get targetSettings() { - return targetSettings; - }, - reset( - nextStoredDraft: typeof storedDraft, - workspaceDefaults = { - envMode: "local" as "local" | "worktree", - startFromOrigin: false, - }, - ) { - storedDraft = nextStoredDraft; - targetSettings = { - defaultThreadEnvMode: workspaceDefaults.envMode, - newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin, - defaultModelSelection: null, - defaultRuntimeMode: "full-access", - }; - router.state.location.href = "/"; - router.navigate.mockClear(); - draftStore.setDraftThreadContext.mockClear(); - draftStore.setLogicalProjectDraftThreadId.mockClear(); - projectFileRead = new Promise((resolve) => { - completeProjectFileRead = resolve; - }); - }, router, + primarySettings: {} as ServerSettings, + targetSettings: {} as ServerSettings, + projects: [] as Project[], + projectFileRead: Promise.resolve(null), + readProjectFile: vi.fn(), }; }); - vi.mock("@effect/atom-react", () => ({ - useAtomValue: (atom: unknown) => - atom === "primary-settings" - ? { newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin } - : new Map([ - [ - "environment-primary", - { - settings: { - ...testState.targetSettings, - newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin, - }, - }, - ], - ["environment-ssh", { settings: testState.targetSettings }], - ]), -})); -vi.mock("@t3tools/client-runtime/environment", () => ({ - scopedProjectKey: () => "remote-project", - scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), - scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), -})); -vi.mock("@t3tools/contracts", () => ({ - DEFAULT_RUNTIME_MODE: "default", - DEFAULT_SERVER_SETTINGS: {}, -})); -vi.mock("@t3tools/shared/projectSettings", () => ({ - // Environment settings pass through; the tests set project fields on the - // project record, which the hook still honors until the server folds them. - // With a file argument the env mode resolves like the real chain. - resolveProjectSettings: ( - settings: Record, - _projectId: unknown, - _project: unknown, - projectFile?: { defaultThreadEnvMode?: "local" | "worktree" } | null, - ) => ({ - settings: - projectFile === undefined - ? settings - : { - ...settings, - defaultThreadEnvMode: - settings.defaultThreadEnvMode ?? projectFile?.defaultThreadEnvMode ?? "local", - }, - sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" }, - overrides: {}, - }), + useAtomValue: () => + new Map([ + ["environment-primary", { settings: testState.primarySettings }], + ["environment-ssh", { settings: testState.targetSettings }], + ]), })); vi.mock("@tanstack/react-router", () => ({ useParams: () => null, useRouter: () => testState.router, })); -vi.mock("react", () => ({ - useCallback: (callback: T) => callback, - useMemo: (factory: () => T) => factory(), -})); vi.mock("../components/Sidebar.logic", () => ({ orderItemsByPreferredIds: () => [] })); -vi.mock("../composerDraftStore", () => { - const useComposerDraftStore = Object.assign(() => null, { - getState: () => testState.draftStore, - }); - return { - composerDraftHasUserContent: () => false, - markPromotedDraftThreadByRef: vi.fn(), - useComposerDraftStore, - }; -}); -vi.mock("../lib/chatThreadActions", async (importOriginal) => ({ - ...(await importOriginal()), - hasExplicitComposerModelSelection: () => false, - resolveNewThreadModelSelectionOverride: () => null, -})); vi.mock("../lib/t3ProjectFileDefaults", () => ({ - readT3ProjectFile: () => testState.projectFileRead, + readT3ProjectFile: (...args: unknown[]) => { + testState.readProjectFile(...args); + return testState.projectFileRead; + }, })); vi.mock("../lib/utils", () => ({ - newDraftId: () => "draft-delayed", - newThreadId: () => "thread-delayed", -})); -vi.mock("../logicalProject", () => ({ - deriveLogicalProjectKeyFromSettings: () => "remote-project", - getProjectOrderKey: () => "remote-project", - selectProjectGroupingSettings: () => ({}), + newDraftId: () => DraftId.make("draft-delayed"), + newThreadId: () => ThreadId.make("thread-delayed"), })); vi.mock("../state/entities", () => ({ - readProjects: () => [ - { - id: "project-remote", - environmentId: "environment-ssh", - workspaceRoot: "/remote/project", - defaultThreadEnvMode: null, - defaultModelSelection: null, - }, - ], + readProjects: () => testState.projects, readThreadShell: () => null, - useProjects: () => [], + useProjects: () => testState.projects, useThread: () => null, })); -vi.mock("../state/server", () => ({ - environmentServerConfigsAtom: {}, - primaryServerSettingsAtom: "primary-settings", -})); -vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null })); +vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {} })); vi.mock("../uiStateStore", () => ({ legacyProjectCwdPreferenceKey: () => "remote-project", useUiStateStore: () => [], })); -vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); +vi.mock("./useSettings", () => ({ + useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), +})); import { useNewThreadHandler } from "./useHandleNewThread"; -describe.each([ - ["new", null], - [ - "reusable", +const projectRef = scopeProjectRef( + EnvironmentId.make("environment-ssh"), + ProjectId.make("project-remote"), +); +const instance = ProviderInstanceId.make("codex"); +const sticky = createModelSelection(instance, "last-used", [ + { id: "reasoningEffort", value: "high" }, +]); +const projectModel = createModelSelection(instance, "project-model"); +const explicit = createModelSelection(instance, "explicit-model"); +const existingDraft = DraftId.make("draft-existing"); +let completeFileRead: (file: T3ProjectFile | null) => void; +let renderer: ReactTestRenderer | undefined; +let openThread: ReturnType; +let logicalProjectKey: string; + +function setup(reuse: boolean, settings: Partial = {}) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + stickyActiveProvider: null, + stickyModelSelectionByProvider: {}, + }); + testState.targetSettings = { + ...DEFAULT_SERVER_SETTINGS, + defaultThreadEnvMode: null, + ...settings, + }; + testState.primarySettings = { + ...DEFAULT_SERVER_SETTINGS, + defaultRuntimeMode: + testState.targetSettings.defaultRuntimeMode === "full-access" + ? "approval-required" + : "full-access", + defaultThreadEnvMode: + testState.targetSettings.defaultThreadEnvMode === "local" ? "worktree" : "local", + newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin, + }; + testState.projects = [ { - draftId: "draft-existing", - environmentId: "environment-ssh", - promotedTo: null, - threadId: "thread-existing", + id: projectRef.projectId, + environmentId: projectRef.environmentId, + title: "Remote", + workspaceRoot: "/remote/project", + repositoryIdentity: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + createdAt: "2026-09-23T00:00:00.000Z", + updatedAt: "2026-09-23T00:00:00.000Z", + scripts: [], }, - ], -])("useNewThreadHandler with a %s draft", (_, draft) => { + ]; + logicalProjectKey = deriveLogicalProjectKeyFromSettings( + testState.projects[0]!, + selectProjectGroupingSettings(DEFAULT_CLIENT_SETTINGS), + ); + if (reuse) { + useComposerDraftStore + .getState() + .setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, existingDraft, { + threadId: ThreadId.make("thread-existing"), + envMode: "local", + }); + useComposerDraftStore + .getState() + .setModelSelection(existingDraft, createModelSelection(instance, "old-implicit-seed")); + } + testState.router.state.location.href = "/"; + testState.router.state.matches = [{ params: {} }]; + testState.router.navigate.mockClear(); + testState.readProjectFile.mockClear(); + testState.projectFileRead = new Promise((resolve) => { + completeFileRead = resolve; + }); + function Probe() { + openThread = useNewThreadHandler(); + return null; + } + act(() => { + renderer = create(createElement(Probe)); + }); +} + +async function finish(pending: ReturnType, file: T3ProjectFile | null = null) { + completeFileRead(file); + const opened = await pending; + expect(opened).not.toBeNull(); + return opened!; +} +function selection(id: DraftId) { + const draft = useComposerDraftStore.getState().getComposerDraft(id); + return draft?.activeProvider ? draft.modelSelectionByProvider[draft.activeProvider] : null; +} + +afterEach(() => { + act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe.each([false, true])("new-thread real store with reuse=%s", (reuse) => { + it("reads the latest sticky model after inherited workspace resolution", async () => { + setup(reuse, { defaultModelSelection: projectModel }); + const pending = openThread(projectRef); + useComposerDraftStore.getState().setStickyModelSelection(sticky); + const opened = await finish(pending); + expect(selection(opened.draftId)).toEqual(sticky); + expect(opened.draftId).toBe(reuse ? existingDraft : "draft-delayed"); + expect(useComposerDraftStore.getState().getDraftSession(opened.draftId)).toMatchObject( + projectRef, + ); + expect( + useComposerDraftStore.getState().getComposerDraft(opened.draftId)?.modelSelectionExplicit, + ).toBeUndefined(); + }); + + it("carries the current server thread's composer model ahead of sticky and project defaults", async () => { + setup(reuse, { defaultModelSelection: projectModel }); + const sourceRef = scopeThreadRef( + EnvironmentId.make("environment-primary"), + ThreadId.make("thread-current"), + ); + const currentModel = createModelSelection(instance, "current-composer", [ + { id: "reasoningEffort", value: "low" }, + ]); + const store = useComposerDraftStore.getState(); + store.setModelSelection(sourceRef, currentModel); + store.setStickyModelSelection(sticky); + testState.router.state.matches = [{ params: sourceRef }]; + testState.router.state.location.href = "/environment-primary/thread-current"; + + const opened = await finish(openThread(projectRef)); + + expect(opened.draftId).toBe(reuse ? existingDraft : "draft-delayed"); + expect(selection(opened.draftId)).toEqual(currentModel); + expect(store.getDraftSession(opened.draftId)).toMatchObject(projectRef); + expect(testState.router.navigate).toHaveBeenCalledExactlyOnceWith({ + to: "/draft/$draftId", + params: { draftId: opened.draftId }, + replace: false, + }); + }); + + it("falls back to the project model without sticky intent", async () => { + setup(reuse, { defaultModelSelection: projectModel }); + const opened = await finish(openThread(projectRef)); + expect(selection(opened.draftId)).toEqual(projectModel); + }); + it.each(["approval-required", "auto-accept-edits", "auto", "full-access"] as const)( - "uses the target environment's %s permissions for new threads", + "uses the destination's %s runtime mode", async (runtimeMode) => { - testState.reset(draft); - testState.targetSettings.defaultRuntimeMode = runtimeMode; - const projectRef = { - environmentId: "environment-ssh", - projectId: "project-remote", - } as never; - const pendingOpen = useNewThreadHandler()(projectRef); - testState.completeProjectFileRead(null); - const opened = await pendingOpen; - - expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( - "remote-project", - projectRef, - opened!.draftId, - expect.objectContaining({ runtimeMode }), + setup(reuse, { defaultRuntimeMode: runtimeMode }); + const opened = await finish(openThread(projectRef)); + expect(useComposerDraftStore.getState().getDraftSession(opened.draftId)?.runtimeMode).toBe( + runtimeMode, ); }, ); - it("abandons a delayed draft open when the user navigates elsewhere", async () => { - testState.reset(draft); - const openThread = useNewThreadHandler(); - const pendingOpen = openThread( - { environmentId: "environment-ssh", projectId: "project-remote" } as never, - { replace: true }, - ); - + it("abandons a delayed draft open after navigation", async () => { + setup(reuse); + const before = useComposerDraftStore.getState().draftThreadsByThreadKey; + const pending = openThread(projectRef, { replace: true }); testState.router.state.location.href = "/usage"; - testState.completeProjectFileRead(null); - await pendingOpen; - + completeFileRead(null); + expect(await pending).toBeNull(); expect(testState.router.state.location.href).toBe("/usage"); expect(testState.router.navigate).not.toHaveBeenCalled(); - expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + expect(useComposerDraftStore.getState().draftThreadsByThreadKey).toBe(before); }); - it.each([true, false])( - "uses the target environment's start-from-origin default of %s", - async (startFromOrigin) => { - testState.reset(draft, { envMode: "worktree", startFromOrigin }); - const openThread = useNewThreadHandler(); - const projectRef = { - environmentId: "environment-ssh", - projectId: "project-remote", - } as never; - const pendingOpen = openThread(projectRef); - - testState.completeProjectFileRead(null); - const opened = await pendingOpen; - - expect(opened).toEqual({ - draftId: draft?.draftId ?? "draft-delayed", - threadId: draft?.threadId ?? "thread-delayed", + it.each(["local", "worktree"] as const)( + "resolves inherited null from t3.json to %s", + async (envMode) => { + setup(reuse, { newWorktreesStartFromOrigin: true }); + const opened = await finish(openThread(projectRef), { defaultThreadEnvMode: envMode }); + expect(testState.readProjectFile).toHaveBeenCalledExactlyOnceWith( + projectRef.environmentId, + "/remote/project", + ); + expect(useComposerDraftStore.getState().getDraftSession(opened.draftId)).toMatchObject({ + envMode, + startFromOrigin: envMode === "worktree", }); - expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( - "remote-project", - projectRef, - opened!.draftId, - expect.objectContaining({ envMode: "worktree", startFromOrigin }), + }, + ); + + it.each(["local", "worktree"] as const)( + "honors explicit environment default %s without consulting t3.json", + async (envMode) => { + setup(reuse, { defaultThreadEnvMode: envMode }); + const opened = await openThread(projectRef); + expect(testState.readProjectFile).not.toHaveBeenCalled(); + expect(useComposerDraftStore.getState().getDraftSession(opened!.draftId)?.envMode).toBe( + envMode, ); - if (draft) { - expect(testState.draftStore.setDraftThreadContext).toHaveBeenCalledWith( - draft.draftId, - expect.objectContaining({ envMode: "worktree", startFromOrigin }), - ); - } }, ); it.each([true, false])( - "preserves an explicit start-from-origin choice of %s", + "uses the destination's start-from-origin default %s", async (startFromOrigin) => { - testState.reset(draft, { envMode: "worktree", startFromOrigin: !startFromOrigin }); - const openThread = useNewThreadHandler(); - const projectRef = { - environmentId: "environment-ssh", - projectId: "project-remote", - } as never; - - const opened = await openThread(projectRef, { envMode: "worktree", startFromOrigin }); + setup(reuse, { + defaultThreadEnvMode: "worktree", + newWorktreesStartFromOrigin: startFromOrigin, + }); + const opened = await openThread(projectRef); + expect(useComposerDraftStore.getState().getDraftSession(opened!.draftId)).toMatchObject({ + envMode: "worktree", + startFromOrigin, + }); + }, + ); - expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( - "remote-project", - projectRef, - opened!.draftId, - expect.objectContaining({ envMode: "worktree", startFromOrigin }), + it.each([true, false])( + "preserves explicit workspace options including start-from-origin %s", + async (startFromOrigin) => { + setup(reuse, { + defaultThreadEnvMode: "local", + newWorktreesStartFromOrigin: !startFromOrigin, + }); + const options = { + envMode: "worktree" as const, + startFromOrigin, + branch: "topic", + worktreePath: "/remote/worktree", + }; + const opened = await openThread(projectRef, options); + expect(useComposerDraftStore.getState().getDraftSession(opened!.draftId)).toMatchObject( + options, ); + expect(testState.readProjectFile).not.toHaveBeenCalled(); }, ); }); + +describe("reusable draft intent", () => { + it("does not carry an already-open draft's implicit model back into itself", async () => { + setup(true, { defaultModelSelection: projectModel }); + const store = useComposerDraftStore.getState(); + store.setStickyModelSelection(sticky); + testState.router.state.matches = [{ params: { draftId: existingDraft } }]; + testState.router.state.location.href = `/draft/${existingDraft}`; + + const opened = await openThread(projectRef); + + expect(opened?.draftId).toBe(existingDraft); + expect(selection(existingDraft)).toEqual(sticky); + expect(testState.readProjectFile).not.toHaveBeenCalled(); + expect(testState.router.navigate).not.toHaveBeenCalled(); + }); + + it("preserves an explicit model instead of sticky and project defaults", async () => { + setup(true, { defaultModelSelection: projectModel }); + const store = useComposerDraftStore.getState(); + store.setStickyModelSelection(sticky); + store.setModelSelection(existingDraft, explicit, { explicit: true }); + const opened = await finish(openThread(projectRef)); + expect(opened.draftId).toBe(existingDraft); + expect(selection(existingDraft)).toEqual(explicit); + expect(store.getComposerDraft(existingDraft)?.modelSelectionExplicit).toBe(true); + }); + + it("keeps an invested draft's prompt and target when minting a fresh draft", async () => { + setup(true, { defaultModelSelection: projectModel }); + const store = useComposerDraftStore.getState(); + store.setPrompt(existingDraft, "Keep this draft intact"); + store.setModelSelection(existingDraft, explicit, { explicit: true }); + store.setStickyModelSelection(sticky); + const opened = await finish(openThread(projectRef)); + expect(opened.draftId).not.toBe(existingDraft); + expect(selection(opened.draftId)).toEqual(sticky); + expect(selection(existingDraft)).toEqual(explicit); + expect(store.getComposerDraft(existingDraft)?.prompt).toBe("Keep this draft intact"); + expect(store.getDraftSession(existingDraft)).toMatchObject(projectRef); + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 327f18080008..1f0f887e73c4 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -94,9 +94,9 @@ export function useNewThreadHandler() { const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref; const currentRouteTarget = getCurrentRouteTarget(); // A new thread carries the user's working mode from the thread being - // viewed. The target project's configured model still wins; interaction - // mode carries independently. Permissions, branch, worktree, and env mode - // come from configured defaults unless the caller passes them explicitly. + // viewed. Interaction mode carries independently. Permissions, branch, + // worktree, and env mode come from configured defaults unless the caller + // passes them explicitly. const carrySourceShell = currentRouteTarget?.kind === "server" ? readThreadShell(currentRouteTarget.threadRef) @@ -137,14 +137,20 @@ export function useNewThreadHandler() { ); const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection; const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; - const resolveModelSelectionOverride = (destinationDraftId: DraftId) => - resolveNewThreadModelSelectionOverride({ + const resolveModelSelectionOverride = (destinationDraftId: DraftId) => { + const { stickyActiveProvider, stickyModelSelectionByProvider } = + useComposerDraftStore.getState(); + return resolveNewThreadModelSelectionOverride({ projectDefaultSelection: projectDefaultModelSelection ?? null, + stickySelection: stickyActiveProvider + ? (stickyModelSelectionByProvider[stickyActiveProvider] ?? null) + : null, carrySelection: carryModelSelection, carrySourceDraftId: currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null, destinationDraftId, }); + }; // The shared resolver owns the priority order. The t3.json read is // skipped entirely when a higher-priority source decides, and its // query atom caches per project after the first call. @@ -267,9 +273,9 @@ export function useNewThreadHandler() { }); } // Model intent: an explicit human pick always stands. Seeds and - // legacy entries alike re-resolve here — sticky first, mirroring - // the mint-fresh path, then the project default or carried - // selection on top. This runs even when the draft is already open: + // legacy entries alike re-resolve here from the current thread, + // then the app-wide sticky choice, then the project default. This + // runs even when the draft is already open: // without it, a changed pin could never reach the draft the user // is looking at, because explicit picks are the only thing the // flag protects. @@ -418,8 +424,8 @@ export function useNewThreadHandler() { applyStickyState(draftId); const modelSelectionOverride = resolveModelSelectionOverride(draftId); if (modelSelectionOverride) { - // Project defaults and carried selections both outrank global sticky - // state. The project default wins when both are present. + // The current thread wins over global sticky state. Project defaults + // only fill in when neither user-wide source has a selection. setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); } await router.navigate({ diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index c145404a74e2..ef1c86bcb82c 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -58,6 +58,7 @@ describe("chatThreadActions", () => { expect( resolveNewThreadModelSelectionOverride({ projectDefaultSelection: null, + stickySelection: null, carrySelection: CARRIED_SELECTION, carrySourceDraftId: "draft-a", destinationDraftId: "draft-a", @@ -69,6 +70,7 @@ describe("chatThreadActions", () => { expect( resolveNewThreadModelSelectionOverride({ projectDefaultSelection: null, + stickySelection: null, carrySelection: CARRIED_SELECTION, carrySourceDraftId: "draft-a", destinationDraftId: "draft-b", @@ -76,15 +78,28 @@ describe("chatThreadActions", () => { ).toEqual(CARRIED_SELECTION); }); - it("keeps the project default above any carried selection", () => { + it("keeps a carried selection above the sticky and project defaults", () => { expect( resolveNewThreadModelSelectionOverride({ projectDefaultSelection: PROJECT_DEFAULT_SELECTION, + stickySelection: PROJECT_DEFAULT_SELECTION, carrySelection: CARRIED_SELECTION, carrySourceDraftId: "draft-a", destinationDraftId: "draft-b", }), - ).toEqual(PROJECT_DEFAULT_SELECTION); + ).toEqual(CARRIED_SELECTION); + }); + + it("keeps the sticky selection above the project default", () => { + expect( + resolveNewThreadModelSelectionOverride({ + projectDefaultSelection: PROJECT_DEFAULT_SELECTION, + stickySelection: CARRIED_SELECTION, + carrySelection: null, + carrySourceDraftId: null, + destinationDraftId: "draft-b", + }), + ).toEqual(CARRIED_SELECTION); }); it("only applies the start-from-origin default to new worktree drafts", () => { diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index c14a26d03d1c..315652e6249a 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -46,13 +46,15 @@ export function resolveNewDraftStartFromOrigin(input: { export function resolveNewThreadModelSelectionOverride(input: { readonly projectDefaultSelection: ModelSelection | null; + readonly stickySelection: ModelSelection | null; readonly carrySelection: ModelSelection | null; readonly carrySourceDraftId: string | null; readonly destinationDraftId: string; }): ModelSelection | null { return ( - input.projectDefaultSelection ?? - (input.carrySourceDraftId === input.destinationDraftId ? null : input.carrySelection) + (input.carrySourceDraftId === input.destinationDraftId ? null : input.carrySelection) ?? + input.stickySelection ?? + input.projectDefaultSelection ); } From c6c22ba078997850f3bebaba6ce067441f7ecfa7 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Tue, 29 Sep 2026 18:26:39 -0400 Subject: [PATCH 07/34] fix(server): Keep each thread's provider event log in its own file --- .../provider/Layers/EventNdjsonLogger.test.ts | 201 +++++++++++++----- .../src/provider/Layers/EventNdjsonLogger.ts | 29 ++- 2 files changed, 174 insertions(+), 56 deletions(-) diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index e87372e03381..e95e4a61aed4 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -17,6 +18,7 @@ import { makeEventNdjsonLogger, makeEventNdjsonLogStore, type PendingRecord, + providerEventLogThreadSegment, writeBatchedMessages, } from "./EventNdjsonLogger.ts"; @@ -30,6 +32,16 @@ function ownedLogPath(basePath: string, segment: string): string { return NodePath.join(NodePath.dirname(basePath), `${stem}.${segment}.log`); } +function threadLogPath(basePath: string, threadId: string): string { + return ownedLogPath(basePath, providerEventLogThreadSegment(threadId)); +} + +function logFileNames(directory: string): Array { + return NodeFS.readdirSync(directory) + .filter((name) => name.endsWith(".log")) + .toSorted(); +} + function parseLogLine(line: string) { const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line); assert.notEqual(match, null); @@ -77,7 +89,7 @@ describe("EventNdjsonLogger", () => { const serialized = encodeUnknownJson(messages); assert.notInclude(serialized, secret); const line = parseLogLine( - NodeFS.readFileSync(ownedLogPath(basePath, "thread-1"), "utf8").trim(), + NodeFS.readFileSync(threadLogPath(basePath, "thread-1"), "utf8").trim(), ); assert.equal(line.payload, '{"truncated":true}'); } finally { @@ -108,8 +120,8 @@ describe("EventNdjsonLogger", () => { ); yield* logger.close(); - const threadOnePath = ownedLogPath(basePath, "thread-1"); - const threadTwoPath = ownedLogPath(basePath, "thread-2"); + const threadOnePath = threadLogPath(basePath, "thread-1"); + const threadTwoPath = threadLogPath(basePath, "thread-2"); assert.equal(NodeFS.existsSync(threadOnePath), true); assert.equal(NodeFS.existsSync(threadTwoPath), true); @@ -132,41 +144,39 @@ describe("EventNdjsonLogger", () => { }), ); - it.effect( - "falls back to a global segment when orchestration thread id is missing or invalid", - () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); - - try { - const logger = yield* makeEventNdjsonLogger(basePath, { stream: "orchestration" }); - assert.notEqual(logger, undefined); - if (!logger) { - return; - } - - yield* logger.write({ id: "evt-no-thread" }, null); - yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId); - yield* logger.close(); - - const globalPath = ownedLogPath(basePath, "_global"); - assert.equal(NodeFS.existsSync(globalPath), true); - const lines = NodeFS.readFileSync(globalPath, "utf8") - .trim() - .split("\n") - .map((line) => parseLogLine(line)); - assert.equal(lines.length, 2); - assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false); - assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false); - assert.equal(lines[0]?.stream, "ORCH"); - assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}'); - assert.equal(lines[1]?.stream, "ORCH"); - assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}'); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); + it.effect("falls back to a global segment when the orchestration thread id is missing", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); + + try { + const logger = yield* makeEventNdjsonLogger(basePath, { stream: "orchestration" }); + assert.notEqual(logger, undefined); + if (!logger) { + return; } - }), + + yield* logger.write({ id: "evt-no-thread" }, null); + yield* logger.write({ id: "evt-empty-thread" }, "" as unknown as ThreadId); + yield* logger.close(); + + const globalPath = ownedLogPath(basePath, "_global"); + assert.equal(NodeFS.existsSync(globalPath), true); + const lines = NodeFS.readFileSync(globalPath, "utf8") + .trim() + .split("\n") + .map((line) => parseLogLine(line)); + assert.equal(lines.length, 2); + assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false); + assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false); + assert.equal(lines[0]?.stream, "ORCH"); + assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}'); + assert.equal(lines[1]?.stream, "ORCH"); + assert.equal(lines[1]?.payload, '{"id":"evt-empty-thread"}'); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), ); it.effect("shares one thread writer across native and canonical streams", () => @@ -184,7 +194,7 @@ describe("EventNdjsonLogger", () => { yield* canonical.write({ type: "item.completed", id: "canonical-event" }, threadId); yield* store.close(); - const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared"), "utf8") + const lines = NodeFS.readFileSync(threadLogPath(basePath, "thread-shared"), "utf8") .trim() .split("\n") .map(parseLogLine); @@ -221,7 +231,7 @@ describe("EventNdjsonLogger", () => { yield* canonical.write({ type: "item.completed", id: "after-close" }, threadId); yield* store.close(); - const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared-close"), "utf8") + const lines = NodeFS.readFileSync(threadLogPath(basePath, "thread-shared-close"), "utf8") .trim() .split("\n") .map(parseLogLine); @@ -246,7 +256,7 @@ describe("EventNdjsonLogger", () => { Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); const basePath = NodePath.join(tempDir, "events.log"); - const threadPath = ownedLogPath(basePath, "thread-batched"); + const threadPath = threadLogPath(basePath, "thread-batched"); try { const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); @@ -268,7 +278,7 @@ describe("EventNdjsonLogger", () => { Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); const basePath = NodePath.join(tempDir, "events.log"); - const threadPath = ownedLogPath(basePath, "thread-interrupted"); + const threadPath = threadLogPath(basePath, "thread-interrupted"); try { const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); @@ -413,7 +423,7 @@ describe("EventNdjsonLogger", () => { yield* native.write({ type: "turn.completed", id: "native-final" }, threadId); yield* store.close(); - const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") + const lines = NodeFS.readFileSync(threadLogPath(basePath, "thread-filtered"), "utf8") .trim() .split("\n") .map(parseLogLine); @@ -457,7 +467,7 @@ describe("EventNdjsonLogger", () => { ); yield* store.close(); - const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-history"), "utf8"); + const contents = NodeFS.readFileSync(threadLogPath(basePath, "large-history"), "utf8"); assert.isBelow(Buffer.byteLength(contents), 2_048); const record = decodeUnknownJson(parseLogLine(contents.trim()).payload); assert.nestedPropertyVal(record, "event.payload.id", 42); @@ -491,7 +501,7 @@ describe("EventNdjsonLogger", () => { yield* logger.write({ id: "escaped", output: "\u0000".repeat(20_000) }, threadId); yield* store.close(); - const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-error"), "utf8"); + const contents = NodeFS.readFileSync(threadLogPath(basePath, "large-error"), "utf8"); const records = contents .trim() .split("\n") @@ -532,7 +542,7 @@ describe("EventNdjsonLogger", () => { threadId, ); yield* store.close(); - const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-diff"), "utf8"); + const contents = NodeFS.readFileSync(threadLogPath(basePath, "large-diff"), "utf8"); assert.isBelow(Buffer.byteLength(contents), 2_048); const record = decodeUnknownJson(parseLogLine(contents.trim()).payload); assert.propertyVal(record, "type", "turn.diff.updated"); @@ -573,7 +583,7 @@ describe("EventNdjsonLogger", () => { yield* store.close(); const payloads = NodeFS.readFileSync( - ownedLogPath(basePath, "thread-tool-lifecycle"), + threadLogPath(basePath, "thread-tool-lifecycle"), "utf8", ) .trim() @@ -614,7 +624,7 @@ describe("EventNdjsonLogger", () => { yield* logger.write(hostile, ThreadId.make("thread-hostile")); yield* logger.close(); - const contents = NodeFS.readFileSync(ownedLogPath(basePath, "thread-hostile"), "utf8"); + const contents = NodeFS.readFileSync(threadLogPath(basePath, "thread-hostile"), "utf8"); assert.notInclude(contents, "blocked"); } finally { NodeFS.rmSync(tempDir, { recursive: true, force: true }); @@ -692,7 +702,7 @@ describe("EventNdjsonLogger", () => { } yield* store.close(); - const fileStem = NodePath.basename(ownedLogPath(basePath, "thread-rotate")); + const fileStem = NodePath.basename(threadLogPath(basePath, "thread-rotate")); const matchingFiles = NodeFS.readdirSync(tempDir) .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`)) .toSorted(); @@ -719,9 +729,9 @@ describe("EventNdjsonLogger", () => { Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); const basePath = NodePath.join(tempDir, "events.log"); - const expiredPath = ownedLogPath(basePath, "expired"); - const oldPath = ownedLogPath(basePath, "old"); - const newPath = ownedLogPath(basePath, "new"); + const expiredPath = threadLogPath(basePath, "expired"); + const oldPath = threadLogPath(basePath, "old"); + const newPath = threadLogPath(basePath, "new"); const unrelatedLogPath = NodePath.join(tempDir, "unrelated.log"); const legacyLogPath = NodePath.join(tempDir, "legacy-thread.log"); const ignoredPath = NodePath.join(tempDir, "ignored.txt"); @@ -763,7 +773,7 @@ describe("EventNdjsonLogger", () => { Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); const basePath = NodePath.join(tempDir, "events.log"); - const activePath = ownedLogPath(basePath, "active"); + const activePath = threadLogPath(basePath, "active"); try { yield* TestClock.setTime(1_800_000_000_000); @@ -822,6 +832,93 @@ describe("EventNdjsonLogger", () => { assert.deepEqual(attributed, [records[0]]); }); + it.effect("keeps delegated threads that share a long id prefix in separate files", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const first = + "thread:delegated-task:command%3Amcp%3Ad41693b3-cbc4-439b-9911-f51c5a0ba5b3%3Adelegate-task%3Amimo-review-live-driver-effort-20260929"; + const retry = `${first}-retry1`; + + try { + const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); + assert.notEqual(logger, undefined); + if (!logger) return; + yield* logger.write({ id: "first-run" }, ThreadId.make(first)); + yield* logger.write({ id: "retry-run" }, ThreadId.make(retry)); + yield* logger.write({ id: "first-run-late" }, ThreadId.make(first)); + yield* logger.close(); + + const files = logFileNames(tempDir); + assert.lengthOf(files, 2, "each delegated thread owns its own log file"); + const contents = files.map((name) => + NodeFS.readFileSync(NodePath.join(tempDir, name), "utf8") + .trim() + .split("\n") + .map((line) => parseLogLine(line).payload), + ); + assert.sameDeepMembers(contents, [ + ['{"id":"first-run"}', '{"id":"first-run-late"}'], + ['{"id":"retry-run"}'], + ]); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("keeps thread ids that normalize alike in separate files", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const ids = [ + "Thread-Case", + "thread-case", + "thread.dot", + "thread-dot", + `thread-${"\u2603".repeat(200)}`, + `thread-${"\u2600".repeat(200)}`, + "!!!", + "???", + ]; + + try { + const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); + assert.notEqual(logger, undefined); + if (!logger) return; + for (const id of ids) { + yield* logger.write({ id }, id as ThreadId); + } + yield* logger.close(); + + const files = logFileNames(tempDir); + assert.lengthOf(files, ids.length); + assert.notInclude(files, "events._global.log", "a supplied id is never global"); + for (const name of files) { + const payloads = NodeFS.readFileSync(NodePath.join(tempDir, name), "utf8") + .trim() + .split("\n"); + assert.lengthOf(payloads, 1, `${name} holds one thread's records`); + } + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it("names thread files deterministically with a bounded length", () => { + const hash = NodeCrypto.createHash("sha256").update("thread-1", "utf8").digest("hex"); + assert.equal(providerEventLogThreadSegment("thread-1"), `thread-1-${hash.slice(0, 32)}`); + assert.equal( + providerEventLogThreadSegment("thread-1"), + providerEventLogThreadSegment("thread-1"), + ); + const long = providerEventLogThreadSegment(`thread-${"x\u00e9".repeat(5_000)}`); + assert.isAtMost(long.length, 100); + assert.match(long, /^[a-z0-9_-]+$/); + assert.match(providerEventLogThreadSegment("!!!"), /^thread-[0-9a-f]{32}$/); + }); + it.effect("reports logical provider log writes to resource attribution", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index dda83fe4a007..0086ae73fdf8 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -5,6 +5,7 @@ * Native and canonical views share batching, rotation, and retention state so * they cannot race while appending to the same thread-scoped file. */ +import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; @@ -36,6 +37,8 @@ const MAX_RECORD_CHARACTERS = 64 * 1024; const MAX_RECORD_FIELDS = 1_024; const MAX_RECORD_DEPTH = 16; const GLOBAL_THREAD_SEGMENT = "_global"; +const THREAD_SEGMENT_PREFIX_MAX_CHARS = 64; +const THREAD_SEGMENT_HASH_HEX_CHARS = 32; const LOG_SCOPE = "provider-observability"; const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); @@ -173,9 +176,22 @@ function logWarning(message: string, context: Record): Effect.E return Effect.logWarning(message, context).pipe(Effect.annotateLogs({ scope: LOG_SCOPE })); } -function resolveThreadSegment(raw: string | null | undefined): string { - const normalized = typeof raw === "string" ? toSafeThreadAttachmentSegment(raw) : null; - return normalized ?? GLOBAL_THREAD_SEGMENT; +/** + * File-name segment for one thread's provider log. The readable prefix is + * lossy (case, punctuation and length), so a hash of the complete raw id keeps + * threads that normalize alike, such as retries of one delegated task, in + * separate files. Records without a thread share the global file. + */ +export function providerEventLogThreadSegment(threadId: string | null | undefined): string { + if (typeof threadId !== "string" || threadId.length === 0) return GLOBAL_THREAD_SEGMENT; + const readable = (toSafeThreadAttachmentSegment(threadId) ?? "thread") + .slice(0, THREAD_SEGMENT_PREFIX_MAX_CHARS) + .replace(/[-_]+$/g, ""); + const hash = NodeCrypto.createHash("sha256") + .update(threadId, "utf8") + .digest("hex") + .slice(0, THREAD_SEGMENT_HASH_HEX_CHARS); + return `${readable.length > 0 ? readable : "thread"}-${hash}`; } function resolveStreamLabel(stream: EventNdjsonStream): string { @@ -758,7 +774,12 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( return Effect.succeed([{ flush: false }, state] as const); } const pending = state.pending; - pending.push({ stream, threadSegment: resolveThreadSegment(threadId), line, bytes }); + pending.push({ + stream, + threadSegment: providerEventLogThreadSegment(threadId), + line, + bytes, + }); const pendingBytes = state.pendingBytes + bytes; const flush = resolved.batchWindowMs === 0 || From 98199406fb4e69b622c24a3ccfb35b795fc34b1e Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Wed, 23 Sep 2026 19:55:40 -0400 Subject: [PATCH 08/34] fix(orchestrator): Steer compatible active model selections --- .../src/orchestration-v2/EffectWorker.ts | 1 - .../src/orchestration-v2/Orchestrator.ts | 22 +- .../SelectionRestart.integration.test.ts | 159 +++++ .../SteeringCompletion.integration.test.ts | 574 +++++++++++++++++- 4 files changed, 747 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 3d94f1baa8df..c6a6a67d0ee0 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -232,7 +232,6 @@ export const executorLayer: Layer.Layer< text: message.text, ...(message.context ? { context: message.context } : {}), attachments: message.attachments, - modelSelection: run.modelSelection, dispatchMode: { type: message.delegatedCompletion === undefined diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index a210ca0b25f7..664243bd3ae0 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3610,11 +3610,31 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId: input.command.threadId, providerInstanceId: targetRun.providerInstanceId, capabilities: session.providerSession.capabilities, - forceRestart: input.forceRestart || selectionChanged, + forceRestart: + input.forceRestart || + (selectionChanged && + (providerInstanceChanged || selectionTransition?.type !== "apply_on_next_turn")), }), ); if (steeringPolicy === "active_steering") { + // A completion's selection is pinned to the running turn, not a new desired selection. + if ( + input.delegatedCompletion === undefined && + !modelSelectionsEqual(input.projection.thread.modelSelection, input.modelSelection) + ) { + yield* emitEvent({ + type: "thread.model-selection-updated", + threadId: input.command.threadId, + providerInstanceId: input.modelSelection.instanceId, + occurredAt: now, + payload: { + ...input.projection.thread, + modelSelection: input.modelSelection, + updatedAt: now, + }, + }); + } yield* appendSteeringMessage({ runId: targetRun.id, nodeId: rootNodeId, diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index 414478b39061..8401ff2871c6 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -537,6 +537,165 @@ it.live("restarts selection as a new attempt and retries after old-session clean ), ); +for (const transition of ["restart_session", "create_with_handoff"] as const) { + it.live(`steer_active completes an incompatible ${transition} replacement attempt`, () => + Effect.scoped( + Effect.gen(function* () { + const name = `steer-${transition}`; + const cwd = yield* checkpointWorkspace(name); + const threadId = ThreadId.make(`thread:${name}`); + const state = yield* Ref.make({ + activeTurn: null, + opened: [], + started: [], + closedSessionCount: 0, + failedReplacementOpen: true, + }); + let steers = 0; + let interrupts = 0; + const base = makeRestartAdapter(state, exclusiveCapabilities); + const adapter: ProviderAdapterV2Shape = { + ...base, + planSelectionTransition: ({ current, target }) => + Effect.succeed( + current.model === target.model + ? { type: "apply_on_next_turn" } + : { type: transition }, + ), + openSession: (input) => + base.openSession(input).pipe( + Effect.map((session) => ({ + ...session, + ensureThread: (input) => + session.ensureThread(input).pipe( + Effect.map((thread) => ({ + ...thread, + id: ProviderThreadId.make(`${thread.id}:${session.providerSessionId}`), + })), + ), + steerTurn: () => + Effect.sync(() => { + steers += 1; + }), + interruptTurn: (input) => + Effect.gen(function* () { + interrupts += 1; + yield* session.interruptTurn(input); + }), + })), + ), + }; + const { projection, captured } = yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${name}:create`), + threadId, + projectId: ProjectId.make(`project:${name}`), + title: name, + modelSelection: initialSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const running = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "provider-turn.updated" && event.payload.status === "running", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${name}:first`), + threadId, + messageId: MessageId.make(`${name}:first`), + text: "first", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(running); + const active = yield* orchestrator.getThreadProjection(threadId); + const runId = active.runs[0]!.id; + const completed = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "run.updated" && + event.payload.id === runId && + event.payload.status === "completed", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${name}:steer`), + threadId, + messageId: MessageId.make(`${name}:steer`), + text: "new selection", + attachments: [], + modelSelection: replacementSelection, + dispatchMode: { type: "steer_active", targetRunId: runId }, + createdBy: "user", + creationSource: "web", + }); + const replacing = yield* orchestrator.getThreadProjection(threadId); + assert.lengthOf(replacing.attempts, 2); + assert.equal(replacing.attempts[0]!.status, "superseded"); + yield* worker.drain(); + yield* Fiber.join(completed); + yield* worker.drain(); + return { + projection: yield* orchestrator.getThreadProjection(threadId), + captured: yield* Ref.get(state), + }; + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name }, + ProviderAdapterRegistry.makeSingleLayer(adapter), + ), + ), + ); + assert.equal(steers, 0); + assert.equal(interrupts, 1); + assert.lengthOf(projection.runs, 1); + assert.deepEqual(projection.thread.modelSelection, replacementSelection); + assert.deepEqual(projection.runs[0]!.modelSelection, replacementSelection); + assert.deepEqual( + projection.attempts.map((attempt) => attempt.status), + ["superseded", "completed"], + ); + assert.deepEqual( + projection.providerTurns.map((turn) => turn.status), + ["interrupted", "completed"], + ); + assert.equal(projection.attempts[1]!.reason, "steering_restart"); + assert.lengthOf(projection.contextHandoffs, transition === "create_with_handoff" ? 1 : 0); + assert.deepEqual( + captured.started.map((turn) => turn.model), + [initialSelection.model, replacementSelection.model], + ); + assert.deepEqual( + captured.opened.map((session) => session.model), + [initialSelection.model, replacementSelection.model], + ); + assert.equal(captured.closedSessionCount, 1); + }), + ), + ); +} + for (const deadStatus of ["stopped", "error"] as const) { it.live( `restarts the live session on a model change when a newer ${deadStatus} session record exists`, diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index bfaf0bb476d1..09fede2c6825 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -11,6 +11,7 @@ import { ProviderTurnId, ThreadId, type OrchestrationV2DomainEvent, + type ModelSelection, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -59,6 +60,11 @@ for (const mailbox of [false, true]) { const steerEntered = yield* Deferred.make(); const rejectSteer = yield* Deferred.make(); let steerCalls = 0; + let interrupts = 0; + let sessionOpens = 0; + let requireRestart = false; + const deliveredSteers: Array<{ messageId: MessageId; providerTurnId: ProviderTurnId }> = + []; const capabilities = { ...CodexProviderCapabilitiesV2, turns: { @@ -70,9 +76,11 @@ for (const mailbox of [false, true]) { instanceId, driver, getCapabilities: () => Effect.succeed(capabilities), - planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + planSelectionTransition: () => + Effect.succeed({ type: requireRestart ? "restart_session" : "apply_on_next_turn" }), openSession: (input) => Effect.gen(function* () { + sessionOpens += 1; const now = yield* DateTime.now; return { instanceId, @@ -136,7 +144,15 @@ for (const mailbox of [false, true]) { steerTurn: (turn) => Effect.gen(function* () { steerCalls += 1; - if (timing === "after delivery") return; + deliveredSteers.push({ + messageId: turn.message.messageId, + providerTurnId: turn.providerTurnId, + }); + if ( + timing === "after delivery" || + turn.message.messageId === "message:desired-selection" + ) + return; yield* Deferred.succeed(steerEntered, undefined); yield* Deferred.await(rejectSteer); return yield* new ProviderAdapterSteerRunError({ @@ -146,7 +162,10 @@ for (const mailbox of [false, true]) { cause: "turn already completed", }); }), - interruptTurn: () => Effect.void, + interruptTurn: () => + Effect.sync(() => { + interrupts += 1; + }), respondToRuntimeRequest: () => Effect.void, readThreadSnapshot: () => Effect.die("unused"), rollbackThread: () => Effect.die("unused"), @@ -199,6 +218,30 @@ for (const mailbox of [false, true]) { const first = started[0]!; const messageId = MessageId.make("message:steering"); const taskId = NodeId.make("task:mailbox"); + const desiredSelection = { + ...modelSelection, + model: "next-turn-model", + options: [{ id: "effort", value: "high" }], + }; + if ((mailbox && timing === "after delivery") || timing === "during delivery") { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("desired-selection"), + threadId, + messageId: MessageId.make("message:desired-selection"), + text: "use the new model next turn", + attachments: [], + modelSelection: desiredSelection, + dispatchMode: { type: "steer_active", targetRunId: first.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + const selected = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(selected.thread.modelSelection, desiredSelection); + assert.deepEqual(selected.runs[0]!.modelSelection, modelSelection); + requireRestart = mailbox && timing === "after delivery"; + } if (mailbox) { const sink = yield* EventSink.EventSinkV2; const current = yield* orchestrator.getThreadProjection(threadId); @@ -255,6 +298,7 @@ for (const mailbox of [false, true]) { ], }); } + const beforeCompletion = yield* orchestrator.getThreadProjection(threadId); const dispatchSteer = orchestrator.dispatch({ type: "message.dispatch", commandId: CommandId.make("steer"), @@ -286,7 +330,41 @@ for (const mailbox of [false, true]) { : {}), }); if (timing !== "before dispatch") yield* dispatchSteer; - if (timing === "after delivery") yield* worker.drain(); + if (timing === "after delivery") { + yield* worker.drain(); + if (mailbox) { + const delivered = yield* orchestrator.getThreadProjection(threadId); + const sink = yield* EventSink.EventSinkV2; + const stored = yield* sink + .readByCommandId({ commandId: CommandId.make("steer") }) + .pipe(Stream.runCollect); + assert.isEmpty( + stored.filter(({ event }) => event.type === "thread.model-selection-updated"), + ); + assert.deepEqual(delivered.thread.modelSelection, desiredSelection); + assert.deepEqual(delivered.runs[0]!.modelSelection, modelSelection); + assert.deepEqual(delivered.attempts, beforeCompletion.attempts); + assert.deepEqual(delivered.providerSessions, beforeCompletion.providerSessions); + assert.deepEqual(delivered.providerTurns, beforeCompletion.providerTurns); + assert.equal(delivered.runs.length, 1); + assert.equal(interrupts, 0); + assert.equal(sessionOpens, 1); + assert.deepEqual( + deliveredSteers.filter((steer) => steer.messageId === messageId), + [ + { + messageId, + providerTurnId: beforeCompletion.providerTurns[0]!.id, + }, + ], + ); + assert.equal( + delivered.messages.find((message) => message.id === messageId)?.runId, + first.runId, + ); + } + } + requireRestart = false; const delivery = timing === "during delivery" ? yield* worker.runOnce.pipe(Effect.forkScoped) : null; if (delivery !== null) yield* Deferred.await(steerEntered); @@ -323,7 +401,7 @@ for (const mailbox of [false, true]) { yield* orchestrator.resumeQueuedRuns; yield* worker.drain(); if (timing === "after delivery") { - assert.equal(steerCalls, 1); + assert.equal(steerCalls, mailbox ? 2 : 1); assert.equal(started.length, 1); if (mailbox) { const delivered = yield* orchestrator.getThreadProjection(threadId); @@ -341,7 +419,11 @@ for (const mailbox of [false, true]) { messageId, }); yield* worker.drain(); - assert.equal(steerCalls, 1); + assert.equal(steerCalls, 2); + assert.equal( + deliveredSteers.filter((steer) => steer.messageId === messageId).length, + 1, + ); yield* orchestrator.dispatch({ type: "delegated_task.completion-delivery.acknowledge", commandId: CommandId.make("read-result"), @@ -354,6 +436,28 @@ for (const mailbox of [false, true]) { acknowledged.subagents[0]?.completionDelivery?.state, "acknowledged", ); + const followingRunning = yield* watch( + (event) => + event.type === "provider-turn.updated" && + event.payload.status === "running" && + event.payload.id !== turn.id, + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("following"), + threadId, + messageId: MessageId.make("message:following"), + text: "continue without an explicit selection", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "agent", + creationSource: "server", + }); + yield* worker.drain(); + yield* Fiber.join(followingRunning); + assert.equal(started.length, 2); + assert.deepEqual(started[1]!.modelSelection, desiredSelection); + assert.deepEqual(started[0]!.modelSelection, modelSelection); } return; } @@ -370,8 +474,22 @@ for (const mailbox of [false, true]) { sizeBytes: 10, }, ]); - assert.equal(steerCalls, timing === "during delivery" ? 1 : 0); + assert.equal(steerCalls, timing === "during delivery" ? 2 : 0); const final = yield* orchestrator.getThreadProjection(threadId); + if (timing === "during delivery") { + assert.deepEqual(started[1]!.modelSelection, desiredSelection); + assert.deepEqual(final.thread.modelSelection, desiredSelection); + assert.deepEqual( + final.runs.find((run) => run.id === first.runId)!.modelSelection, + modelSelection, + ); + assert.deepEqual( + final.runs.find((run) => run.id === started[1]!.runId)!.modelSelection, + desiredSelection, + ); + assert.equal(final.runs.length, 2); + assert.equal(interrupts, 0); + } assert.equal(final.messages.filter((message) => message.id === messageId).length, 1); assert.equal( final.messages.find((message) => message.id === messageId)?.runId, @@ -401,3 +519,445 @@ for (const mailbox of [false, true]) { ); } } + +it.effect("steers a Pi-like turn when composer selection differs but stays turn-scoped", () => + Effect.scoped( + Effect.gen(function* () { + // Replay cannot express this: the failure happens in command policy + // before a provider transcript exists. Pi advertises active steering + // without interrupt-restart; a composer option diff must not force restart. + const cwd = yield* checkpointWorkspace("steer-turn-scoped-selection"); + const events = yield* Queue.unbounded(); + const started: ProviderAdapterV2TurnInput[] = []; + const steered: string[] = []; + let interrupts = 0; + const piInstanceId = ProviderInstanceId.make("pi"); + const runningSelection = { + instanceId: piInstanceId, + model: "grok-4.6", + options: [{ id: "thinking", value: "high" }], + } satisfies ModelSelection; + const composerSelection = { + instanceId: piInstanceId, + model: "grok-4.6", + options: [{ id: "thinking", value: "low" }], + } satisfies ModelSelection; + const now = yield* DateTime.now; + const piCapabilities = { + ...CodexProviderCapabilitiesV2, + turns: { + ...CodexProviderCapabilitiesV2.turns, + supportsActiveSteering: true, + supportsSteeringByInterruptRestart: false, + }, + }; + let transition: "apply_on_next_turn" | "restart_session" | "create_with_handoff" | "reject" = + "apply_on_next_turn"; + const adapter: ProviderAdapterV2Shape = { + instanceId: piInstanceId, + driver: ProviderDriverKind.make("pi"), + getCapabilities: () => Effect.succeed(piCapabilities), + planSelectionTransition: () => + Effect.succeed( + transition === "reject" + ? { type: "reject", reason: "Unsupported selection" } + : { type: transition }, + ), + openSession: (input) => + Effect.succeed({ + instanceId: piInstanceId, + driver: ProviderDriverKind.make("pi"), + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver: ProviderDriverKind.make("pi"), + providerInstanceId: piInstanceId, + status: "ready", + cwd, + model: runningSelection.model, + capabilities: piCapabilities, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + ensureThread: ({ threadId }) => + Effect.succeed({ + id: ProviderThreadId.make(`provider-thread:${threadId}`), + driver: ProviderDriverKind.make("pi"), + providerInstanceId: piInstanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { + driver: ProviderDriverKind.make("pi"), + nativeId: "native-thread", + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => + Effect.gen(function* () { + started.push(turn); + const startedAt = yield* DateTime.now; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver: ProviderDriverKind.make("pi"), + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: { + driver: ProviderDriverKind.make("pi"), + nativeId: `native:${turn.attemptId}`, + strength: "strong", + }, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }, + }); + }), + steerTurn: (turn) => + Effect.sync(() => { + steered.push(turn.message.text); + }), + interruptTurn: () => + Effect.sync(() => { + interrupts += 1; + }), + respondToRuntimeRequest: () => Effect.void, + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }), + }; + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("thread:steer-turn-scoped-selection"); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:steer-turn-scoped-selection"), + title: "Steer turn-scoped selection", + modelSelection: runningSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const running = yield* watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("first"), + threadId, + messageId: MessageId.make("message:first"), + text: "first", + attachments: [], + modelSelection: runningSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(running); + const first = started[0]; + assert.isDefined(first); + const active = yield* orchestrator.getThreadProjection(threadId); + const sink = yield* EventSink.EventSinkV2; + const assertDesiredSelection = Effect.fnUntraced(function* ( + selection: ModelSelection, + command: string, + changed: boolean, + ) { + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(projection.thread.modelSelection, selection); + assert.deepEqual(projection.runs, active.runs); + assert.deepEqual(projection.attempts, active.attempts); + assert.deepEqual(projection.providerSessions, active.providerSessions); + assert.deepEqual(projection.providerThreads, active.providerThreads); + assert.deepEqual(projection.providerTurns, active.providerTurns); + assert.equal(started.length, 1); + assert.equal(interrupts, 0); + const stored = yield* sink + .readByCommandId({ commandId: CommandId.make(command) }) + .pipe(Stream.runCollect); + const updates = stored.filter( + ({ event }) => event.type === "thread.model-selection-updated", + ); + assert.equal(updates.length, changed ? 1 : 0); + if (changed) { + const event = updates[0]!.event; + assert.equal(event.type, "thread.model-selection-updated"); + if (event.type === "thread.model-selection-updated") + assert.deepEqual(event.payload.modelSelection, selection); + } + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("steer"), + threadId, + messageId: MessageId.make("message:steer"), + text: "keep going", + attachments: [], + modelSelection: composerSelection, + dispatchMode: { type: "steer_active", targetRunId: first.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + assert.deepEqual(steered, ["keep going"]); + yield* assertDesiredSelection(composerSelection, "steer", true); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("steer-model"), + threadId, + messageId: MessageId.make("message:steer-model"), + text: "change model next turn", + attachments: [], + modelSelection: { ...composerSelection, model: "another-model" }, + dispatchMode: { type: "steer_active", targetRunId: first.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + assert.deepEqual(steered, ["keep going", "change model next turn"]); + const futureSelection = { ...composerSelection, model: "another-model" }; + yield* assertDesiredSelection(futureSelection, "steer-model", true); + for (const [step, selection, changed] of [ + ["back-to-running", runningSelection, true], + ["repeat-running", runningSelection, false], + ["future-again", futureSelection, true], + ["repeat", futureSelection, false], + ] as const) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(step), + threadId, + messageId: MessageId.make(`message:${step}`), + text: step, + attachments: [], + modelSelection: selection, + dispatchMode: { type: "steer_active", targetRunId: first.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* assertDesiredSelection(selection, step, changed); + } + const beforeRejection = yield* orchestrator.getThreadProjection(threadId); + const acceptedSteers = [...steered]; + for (const policy of [ + "reject", + "restart_session", + "create_with_handoff", + "cross_instance", + "explicit_restart", + ] as const) { + transition = + policy === "restart_session" || policy === "create_with_handoff" || policy === "reject" + ? policy + : "apply_on_next_turn"; + const result = yield* orchestrator + .dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`blocked:${policy}`), + threadId, + messageId: MessageId.make(`message:blocked:${policy}`), + text: "must not steer", + attachments: [], + modelSelection: + policy === "cross_instance" + ? { ...composerSelection, instanceId: ProviderInstanceId.make("other") } + : composerSelection, + dispatchMode: { + type: policy === "explicit_restart" ? "restart_active" : "steer_active", + targetRunId: first.runId, + }, + createdBy: "user", + creationSource: "web", + }) + .pipe(Effect.result); + assert.equal(result._tag, "Failure", policy); + assert.deepEqual(yield* orchestrator.getThreadProjection(threadId), beforeRejection); + const stored = yield* sink + .readByCommandId({ commandId: CommandId.make(`blocked:${policy}`) }) + .pipe(Stream.runCollect); + assert.isEmpty(stored); + } + yield* worker.drain(); + assert.deepEqual(steered, acceptedSteers); + yield* assertDesiredSelection(futureSelection, "future-again", true); + transition = "apply_on_next_turn"; + for (const [creationSource, selection] of [ + ["server", runningSelection], + ["mcp", futureSelection], + ] as const) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`agent:${creationSource}`), + threadId, + messageId: MessageId.make(`message:agent:${creationSource}`), + text: "explicit agent selection", + attachments: [], + modelSelection: selection, + dispatchMode: { type: "steer_active", targetRunId: first.runId }, + createdBy: "agent", + creationSource, + }); + yield* worker.drain(); + yield* assertDesiredSelection(selection, `agent:${creationSource}`, true); + } + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("queued"), + threadId, + messageId: MessageId.make("message:queued"), + text: "promote this queued message", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queued = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + assert.deepEqual(queued.modelSelection, futureSelection); + const promotionSelection = { ...futureSelection, model: "promotion-model" }; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("select-before-promotion"), + threadId, + messageId: MessageId.make("message:select-before-promotion"), + text: "select before promotion", + attachments: [], + modelSelection: promotionSelection, + dispatchMode: { type: "steer_active", targetRunId: first.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote"), + threadId, + queuedRunId: queued.id, + targetRunId: first.runId, + }); + yield* worker.drain(); + const promoted = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(promoted.thread.modelSelection, promotionSelection); + assert.deepEqual( + promoted.runs.find((run) => run.id === first.runId), + active.runs[0], + ); + assert.deepEqual( + promoted.attempts.filter((attempt) => attempt.runId === first.runId), + active.attempts, + ); + assert.deepEqual(promoted.providerSessions, active.providerSessions); + assert.deepEqual(promoted.providerTurns, active.providerTurns); + assert.equal(promoted.runs.find((run) => run.id === queued.id)?.status, "cancelled"); + assert.equal( + promoted.messages.find((message) => message.id === queued.userMessageId)?.runId, + first.runId, + ); + assert.equal(steered.filter((text) => text === "promote this queued message").length, 1); + assert.equal(started.length, 1); + assert.equal(interrupts, 0); + const promotionEvents = yield* sink + .readByCommandId({ commandId: CommandId.make("promote") }) + .pipe(Stream.runCollect); + assert.isEmpty( + promotionEvents.filter(({ event }) => event.type === "thread.model-selection-updated"), + ); + const completed = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === first.runId && + event.payload.status === "waiting", + ); + const turn = active.providerTurns[0]!; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver: adapter.driver, + providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver: adapter.driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal: first.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(completed); + yield* worker.drain(); + const followingRunning = yield* watch( + (event) => + event.type === "provider-turn.updated" && + event.payload.status === "running" && + event.payload.id !== turn.id, + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("following"), + threadId, + messageId: MessageId.make("message:following"), + text: "follow up without selection", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "server", + }); + yield* worker.drain(); + yield* Fiber.join(followingRunning); + assert.equal(started.length, 2); + assert.deepEqual(started[1]!.modelSelection, promotionSelection); + assert.deepEqual(started[0]!.modelSelection, runningSelection); + const final = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + final.runs.find((run) => run.id === first.runId)!.modelSelection, + runningSelection, + ); + assert.deepEqual( + final.runs.find((run) => run.id === started[1]!.runId)!.modelSelection, + promotionSelection, + ); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: "steer-turn-scoped-selection" }, + ProviderAdapterRegistry.makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + }), + ), +); From 32c37aee52471bdeea30fe218e97d7e68c8064b3 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Thu, 24 Sep 2026 10:33:09 -0400 Subject: [PATCH 09/34] fix(orchestrator): Safely admit execution after uncertain selection --- .../Adapters/AcpAdapterV2.test.ts | 13 + .../orchestration-v2/Adapters/AcpAdapterV2.ts | 311 ++++++++- .../Adapters/AcpAppliedSelection.test.ts | 42 ++ .../Adapters/ClaudeAdapterV2.test.ts | 264 +++++-- .../Adapters/ClaudeAdapterV2.ts | 176 ++++- .../Adapters/ClaudeInitialization.test.ts | 109 +++ .../Adapters/CodexAdapterV2.ts | 10 + .../Adapters/GrokAdapterV2.ts | 19 +- .../Adapters/OpenCodeAdapterV2.test.ts | 55 +- .../Adapters/OpenCodeAdapterV2.ts | 12 +- .../BufferPolicyClaude.integration.test.ts | 441 ++++++++++++ .../BufferPolicyCodex.integration.test.ts | 378 ++++++++++ .../BufferPolicyPooled.integration.test.ts | 318 +++++++++ .../src/orchestration-v2/EffectOutbox.ts | 1 + .../src/orchestration-v2/EffectWorker.ts | 3 + .../ExecutionAdmission.integration.test.ts | 356 ++++++++++ .../src/orchestration-v2/Orchestrator.ts | 426 ++++++++++-- .../src/orchestration-v2/ProviderAdapter.ts | 65 ++ .../src/orchestration-v2/ProviderFailure.ts | 8 + .../ProviderSessionManager.test.ts | 286 +++++++- .../ProviderSessionManager.ts | 646 ++++++++++++++++-- .../orchestration-v2/ProviderSwitchService.ts | 138 ++-- .../ProviderTurnControlService.test.ts | 30 + .../ProviderTurnControlService.ts | 6 +- .../ProviderTurnStartService.ts | 347 +++++----- .../ReadmissionBuffer.integration.test.ts | 562 +++++++++++++++ .../ReadmissionFailure.integration.test.ts | 396 +++++++++++ ...admissionNontransitive.integration.test.ts | 529 ++++++++++++++ ...tionNotificationClaude.integration.test.ts | 326 +++++++++ .../SelectionRecoveryAcp.integration.test.ts | 397 +++++++++++ ...electionRecoveryClaude.integration.test.ts | 401 +++++++++++ ...coveryCodexFailedStart.integration.test.ts | 373 ++++++++++ ...ectionRecoveryOpenCode.integration.test.ts | 159 +++++ ...yOpenCodeStopAdmission.integration.test.ts | 222 ++++++ .../SelectionSettledAcp.integration.test.ts | 563 +++++++++++++++ .../src/orchestration-v2/runtimeLayer.test.ts | 4 +- .../testkit/OrchestratorScenario.ts | 41 +- .../testkit/ProviderReplayHarness.ts | 6 +- ...kAfterRestartAdmission.integration.test.ts | 137 ++++ .../src/provider/acp/AcpSessionRuntime.ts | 22 +- 40 files changed, 8148 insertions(+), 450 deletions(-) create mode 100644 apps/server/src/orchestration-v2/Adapters/AcpAppliedSelection.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/ClaudeInitialization.test.ts create mode 100644 apps/server/src/orchestration-v2/BufferPolicyClaude.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/BufferPolicyCodex.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/BufferPolicyPooled.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/ExecutionAdmission.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/ReadmissionBuffer.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/ReadmissionFailure.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/ReadmissionNontransitive.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/SelectionNotificationClaude.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/SelectionRecoveryAcp.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/SelectionRecoveryClaude.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/SelectionRecoveryCodexFailedStart.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/SelectionRecoveryOpenCode.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/SelectionRecoveryOpenCodeStopAdmission.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/SelectionSettledAcp.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/testkit/RollbackAfterRestartAdmission.integration.test.ts diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 33b91614b4da..5d11bdf01c0a 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -9588,6 +9588,18 @@ describe("AcpAdapterV2", () => { yield* hasPendingBackgroundWork, "the queued continuation must retain ownership of its buffered wake traffic", ); + assert.isDefined(runtime.hasBufferedOutputForThread); + assert.isTrue(yield* runtime.hasBufferedOutputForThread!(providerThread)); + assert.isFalse( + yield* runtime.hasBufferedOutputForThread!({ + ...providerThread, + nativeThreadRef: { + driver: ACP_TEST_DRIVER, + nativeId: "other-session", + strength: "strong", + }, + }), + ); yield* runtime.startTurn( makeTurnInput({ @@ -9624,6 +9636,7 @@ describe("AcpAdapterV2", () => { } assert.equal(continuationTerminalStatus, "completed"); assert.isTrue(bufferedTextSeen, "continuation must drain the wake buffer after the user run"); + assert.isFalse(yield* runtime.hasBufferedOutputForThread!(providerThread)); assert.isFalse(yield* hasPendingBackgroundWork); }).pipe(Effect.provide(testLayer), Effect.scoped), ); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index bf01d9cb2d7d..b5a3d67e3798 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -47,6 +47,7 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; @@ -249,7 +250,9 @@ export interface AcpAdapterV2Flavor { readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; readonly startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult; readonly modelSelection: ModelSelection; + readonly force?: boolean; }) => Effect.Effect; + readonly canForceModelSelection?: boolean; /** Native session mode to select for a runtime policy (e.g. Antigravity `yolo`). */ readonly sessionModeForPolicy?: ( policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, @@ -1306,6 +1309,35 @@ export function acpCarryoverTerminalShouldClearContinuation(input: { return !input.continuationOffered && input.wakeBufferLength === 0; } +export function acpInitialAppliedSelection( + setup: AcpSessionRuntime.AcpSessionRuntimeStartResult["sessionSetupResult"], + selection: ModelSelection, + initialModeId: string | undefined, + attemptsModel: boolean, +): Omit & { readonly model?: string } { + const initialModel = + setup.models?.currentModelId ?? + setup.configOptions?.find((option) => option.category === "model")?.currentValue; + return { + instanceId: selection.instanceId, + ...(typeof initialModel === "string" + ? { model: initialModel } + : !attemptsModel + ? { model: selection.model } + : {}), + ...(selection.options === undefined + ? {} + : { + options: selection.options.flatMap((option) => { + const value = + setup.configOptions?.find((candidate) => candidate.id === option.id)?.currentValue ?? + (option.id === ACP_SESSION_MODE_OPTION_ID ? initialModeId : undefined); + return value === undefined ? [] : [{ id: option.id, value }]; + }), + }), + }; +} + export function acpTurnStartShouldPreserveContinuation(input: { readonly continuationRequested: boolean; readonly isContinuationTurn: boolean; @@ -1616,6 +1648,10 @@ export function makeAcpAdapterV2( const activeSessionSetup = yield* Ref.make(null); const activeSelection = yield* Ref.make(null); + const appliedSelections = yield* Ref.make< + ReadonlyMap & { readonly model?: string }> + >(new Map()); + const configurationAcknowledged = yield* Ref.make(false); const activeInteractionMode = yield* Ref.make(null); const promptInstructionStates = yield* Ref.make(new Map()); const runtimeRestartRequired = yield* Ref.make(false); @@ -1896,6 +1932,7 @@ export function makeAcpAdapterV2( offered, })); }); + const wakeSelections = new WeakMap(); const continuationRequested = yield* Ref.make(false); const continuationGeneration = yield* Ref.make(0); const continuationPermit = yield* Semaphore.make(1); @@ -3862,6 +3899,14 @@ export function makeAcpAdapterV2( !isInTurnHandledAgentChatter && acpPostSettleWakeShouldBuffer(notification, backgroundWorkRunning) ) { + const producingSelection = (yield* Ref.get(appliedSelections)).get( + notification.sessionId, + ); + if (producingSelection?.model !== undefined) + wakeSelections.set(notification, { + ...producingSelection, + model: producingSelection.model, + }); yield* Ref.update(wakeBuffer, (current) => [...current, notification]); buffered = true; } @@ -6106,19 +6151,136 @@ export function makeAcpAdapterV2( return activated; }); + const applyConfigurationStep = Effect.fnUntraced(function* ( + sessionId: string, + optionId: string, + value: string | boolean | ((result: A) => string | boolean | undefined), + operation: Effect.Effect, + recordSelection = true, + ) { + const result = yield* Effect.exit(operation); + if (Exit.isFailure(result)) { + const error = Cause.findErrorOption(result.cause); + if (Option.isNone(error) || !Schema.is(EffectAcpErrors.AcpRequestError)(error.value)) { + yield* Effect.logWarning("orchestration-v2.acp-selection-step-indeterminate", { + driver, + sessionId, + optionId, + }); + } + return yield* result; + } + const appliedValue = typeof value === "function" ? value(result.value) : value; + yield* Ref.update(appliedSelections, (current) => { + const previous = current.get(sessionId); + if (previous === undefined || !recordSelection || appliedValue === undefined) + return current; + const selection = + optionId === "model" + ? { ...previous, model: String(appliedValue) } + : { + ...previous, + options: [ + ...(previous.options ?? []).filter((option) => option.id !== optionId), + { id: optionId, value: appliedValue }, + ], + }; + return new Map(current).set(sessionId, selection); + }); + return result.value; + }); + const configureSession = Effect.fnUntraced(function* ( startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult, modelSelection: ModelSelection, runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + force = false, ) { + yield* Ref.set(configurationAcknowledged, false); + yield* Ref.set(activeSelection, null); const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; + const attemptsModel = + flavor.applyModelSelection !== undefined || + (requestedModel.length > 0 && + !["auto", "default"].includes(requestedModel) && + startResult.sessionSetupResult.configOptions?.some( + (option) => option.category === "model", + ) === true); + if (!(yield* Ref.get(appliedSelections)).has(startResult.sessionId)) { + const initialMode = yield* runtime.getModeState; + yield* Ref.update(appliedSelections, (current) => + new Map(current).set( + startResult.sessionId, + acpInitialAppliedSelection( + startResult.sessionSetupResult, + modelSelection, + initialMode?.currentModeId, + attemptsModel, + ), + ), + ); + } + const step = ( + id: string, + value: string | boolean | ((result: A) => string | boolean | undefined), + operation: Effect.Effect, + ) => applyConfigurationStep(startResult.sessionId, id, value, operation); + const rememberOption = (id: string, value: string | boolean | undefined) => + Ref.update(appliedSelections, (current) => { + const previous = current.get(startResult.sessionId); + if ( + previous === undefined || + value === undefined || + previous.options?.some((option) => option.id === id) + ) + return current; + return new Map(current).set(startResult.sessionId, { + ...previous, + options: [...(previous.options ?? []), { id, value }], + }); + }); + const setConfig = Effect.fnUntraced(function* (id: string, value: string | boolean) { + const previous = (yield* runtime.getConfigOptions).find( + (option) => option.id === id, + )?.currentValue; + yield* rememberOption(id, previous); + return yield* step(id, value, runtime.setConfigOption(id, value, force)); + }); + const setMode = Effect.fnUntraced(function* (mode: string) { + if (modelSelection.options?.some((option) => option.id === ACP_SESSION_MODE_OPTION_ID)) + yield* rememberOption( + ACP_SESSION_MODE_OPTION_ID, + (yield* runtime.getModeState)?.currentModeId, + ); + const prior = (yield* Ref.get(appliedSelections)).get(startResult.sessionId); + const nativeModeId = (yield* runtime.getConfigOptions).find( + (option) => option.category === "mode", + )?.id; + const modeId = prior?.options?.find( + (option) => option.id === ACP_SESSION_MODE_OPTION_ID || option.id === nativeModeId, + )?.id; + return yield* applyConfigurationStep( + startResult.sessionId, + modeId ?? ACP_SESSION_MODE_OPTION_ID, + mode, + runtime.setMode(mode, force), + modeId !== undefined, + ); + }); let appliedModel: string | undefined; if (flavor.applyModelSelection !== undefined) { - appliedModel = yield* flavor.applyModelSelection({ - runtime, - startResult, - modelSelection, - }); + // Record the selection's own model id, not the native id it maps to, + // so buffered output compares equal to the thread's selection. + appliedModel = yield* step( + "model", + modelSelection.model, + flavor.applyModelSelection({ + runtime, + startResult, + modelSelection, + force, + }), + ); } else if ( requestedModel.length > 0 && requestedModel !== "auto" && @@ -6129,7 +6291,7 @@ export function makeAcpAdapterV2( (option) => option.category === "model", ) === true; if (hasModelConfig) { - yield* runtime.setModel(requestedModel); + yield* step("model", modelSelection.model, runtime.setModel(requestedModel, force)); } } // Same-runtime switches compare against this stored setup, so keep @@ -6206,20 +6368,21 @@ export function makeAcpAdapterV2( ); if (!advertisedValues.includes(selection.value)) continue; } - yield* runtime.setConfigOption(selection.id, selection.value).pipe( + yield* setConfig(selection.id, selection.value).pipe( Effect.catchTags({ - AcpRequestError: (error) => - Effect.logWarning("ACP session rejected a configuration option value", { + AcpRequestError: (error) => { + return Effect.logWarning("ACP session rejected a configuration option value", { optionId: selection.id, value: selection.value, detail: error.message, - }), + }); + }, }), ); } const policyMode = flavor.sessionModeForPolicy?.(runtimePolicy); if (policyMode !== undefined) { - yield* runtime.setMode(policyMode); + yield* setMode(policyMode); } const modeState = yield* runtime.getModeState; // The synthetic mode selection is skipped rather than failed when the @@ -6229,9 +6392,9 @@ export function makeAcpAdapterV2( modeSelection !== undefined && typeof modeSelection.value === "string" && modeState?.availableModes.some((mode) => mode.id === modeSelection.value) === true && - modeState.currentModeId !== modeSelection.value + (force || modeState.currentModeId !== modeSelection.value) ) { - yield* runtime.setMode(modeSelection.value); + yield* setMode(modeSelection.value); } const effectiveModeState = yield* runtime.getModeState; const effectiveConfigOptions = yield* runtime.getConfigOptions; @@ -6260,8 +6423,11 @@ export function makeAcpAdapterV2( const planMode = effectiveModeState?.availableModes.find( (mode) => mode.id === "plan" || mode.id === "architect", ); - if (planMode !== undefined && effectiveModeState?.currentModeId !== planMode.id) { - yield* runtime.setMode(planMode.id); + if ( + planMode !== undefined && + (force || effectiveModeState?.currentModeId !== planMode.id) + ) { + yield* setMode(planMode.id); } for (const option of planSensitiveOptions) { const choices = option.options.flatMap((entry) => @@ -6270,8 +6436,8 @@ export function makeAcpAdapterV2( const requested = choices.find( (choice) => choice === "plan" || choice === "architect", ); - if (requested !== undefined && option.currentValue !== requested) { - yield* runtime.setConfigOption(option.id, requested); + if (requested !== undefined && (force || option.currentValue !== requested)) { + yield* setConfig(option.id, requested); } } } else { @@ -6279,12 +6445,12 @@ export function makeAcpAdapterV2( if (nativeBuild !== undefined) { if ( nativeBuild.modeId !== undefined && - effectiveModeState?.currentModeId !== nativeBuild.modeId && + (force || effectiveModeState?.currentModeId !== nativeBuild.modeId) && effectiveModeState?.availableModes.some( (mode) => mode.id === nativeBuild.modeId, ) === true ) { - yield* runtime.setMode(nativeBuild.modeId); + yield* setMode(nativeBuild.modeId); } for (const saved of nativeBuild.configOptions) { const option = effectiveConfigOptions.find( @@ -6294,13 +6460,19 @@ export function makeAcpAdapterV2( const choices = option.options.flatMap((entry) => "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), ); - if (option.currentValue !== saved.value && choices.includes(saved.value)) { - yield* runtime.setConfigOption(option.id, saved.value); + if ( + (force || option.currentValue !== saved.value) && + choices.includes(saved.value) + ) { + yield* setConfig(option.id, saved.value); } } nativeBuildConfigurationBySessionId.delete(startResult.sessionId); } } + // Skipped and explicitly rejected options are settled outcomes. Only + // a step without a response leaves planning evidence unknown. + yield* Ref.set(configurationAcknowledged, true); yield* ( flavor.onSessionConfigurationUpdate?.( yield* runtime.getConfigOptions, @@ -6690,6 +6862,8 @@ export function makeAcpAdapterV2( if (!restartRequired) return false; yield* restartAcpRuntime(threadId); yield* Ref.set(runtimeRestartRequired, false); + yield* Ref.set(appliedSelections, new Map()); + yield* Ref.set(configurationAcknowledged, false); yield* Ref.set(activeSessionId, null); yield* Ref.set(activeSessionSetup, null); yield* Ref.set(activeSelection, null); @@ -6723,19 +6897,33 @@ export function makeAcpAdapterV2( const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired( turnInput.threadId, ); + const isContinuationTurn = + postSettleContinuationEnabled && acpIsProviderContinuationMessage(turnInput.message); const needsSessionActivation = (yield* Ref.get(activeSessionId)) !== requestedSessionId || restartAfterInterrupt; - if (needsSessionActivation) { + if (isContinuationTurn && needsSessionActivation) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: "Buffered output belongs to another native session", + }); + } + if (!isContinuationTurn && needsSessionActivation) { const activated = yield* activateSession(requestedSessionId, turnInput.threadId); yield* Ref.set(activeSessionId, activated.sessionId); yield* Ref.set(activeSessionSetup, activated); - yield* configureSession(activated, turnInput.modelSelection, turnInput.runtimePolicy); + yield* configureSession( + activated, + turnInput.modelSelection, + turnInput.runtimePolicy, + turnInput.reapplySelection, + ); yield* Ref.set(activeSelection, turnInput.modelSelection); yield* Ref.set(activeInteractionMode, turnInput.runtimePolicy.interactionMode); - } else { + } else if (!isContinuationTurn) { const configuredSelection = yield* Ref.get(activeSelection); const configuredInteractionMode = yield* Ref.get(activeInteractionMode); if ( + turnInput.reapplySelection === true || configuredSelection === null || !modelSelectionsEqual(configuredSelection, turnInput.modelSelection) || configuredInteractionMode !== turnInput.runtimePolicy.interactionMode @@ -6751,6 +6939,7 @@ export function makeAcpAdapterV2( currentSessionSetup, turnInput.modelSelection, turnInput.runtimePolicy, + turnInput.reapplySelection, ); yield* Ref.set(activeSelection, turnInput.modelSelection); yield* Ref.set(activeInteractionMode, turnInput.runtimePolicy.interactionMode); @@ -6764,8 +6953,6 @@ export function makeAcpAdapterV2( yield* Ref.set(handledBackgroundTaskIdsInActiveTurn, new Set()); // Continuation turns attach to wake traffic the agent already produced // after the prior root turn settled; do not re-prompt the ACP session. - const isContinuationTurn = - postSettleContinuationEnabled && acpIsProviderContinuationMessage(turnInput.message); const isAppOwnedWakeTurn = acpIsAppOwnedWakeTurn(turnInput.message); // An app-owned wake reports on a sibling delegated child and owns // none of this session's background work, so it must not discard @@ -6942,11 +7129,16 @@ export function makeAcpAdapterV2( offered: noWakeReports.offered, })); const drainedWakeCount = yield* Ref.modify(wakeBuffer, (current) => { - const next: Array = []; - return [ - current.filter((notification) => notification.sessionId === requestedSessionId), - next, - ] as const; + const boundary = current.findIndex((notification) => { + const selection = wakeSelections.get(notification); + return ( + notification.sessionId !== requestedSessionId || + selection === undefined || + !modelSelectionsEqual(selection, turnInput.modelSelection) + ); + }); + const count = boundary < 0 ? current.length : boundary; + return [current.slice(0, count), current.slice(count)] as const; }).pipe( Effect.tap((drained) => Effect.forEach(drained, handleSessionUpdate, { @@ -7148,8 +7340,51 @@ export function makeAcpAdapterV2( providerSessionId: input.providerSessionId, providerSession, events: Stream.fromEffectRepeat(Queue.take(events)), + selectionAcknowledged: (selection) => + Effect.gen(function* () { + const acknowledged = yield* Ref.get(activeSelection); + return ( + (yield* Ref.get(configurationAcknowledged)) && + acknowledged !== null && + modelSelectionsEqual(acknowledged, selection) + ); + }), + reappliesFullSelection: Effect.fnUntraced(function* (selection) { + const sessionId = yield* Ref.get(activeSessionId); + const applied = + sessionId === null ? undefined : (yield* Ref.get(appliedSelections)).get(sessionId); + return ( + (flavor.applyModelSelection === undefined || + flavor.canForceModelSelection === true) && + !["auto", "default", "grok-build"].includes(selection.model) && + applied !== undefined && + (applied.options ?? []).every((option) => + selection.options?.some((next) => next.id === option.id), + ) + ); + }), ...(postSettleContinuationEnabled ? { + continuationDrainsOutput: true, + bufferedExecutionSelection: (providerThread) => + Effect.gen(function* () { + const first = (yield* Ref.get(wakeBuffer))[0]; + if ( + first === undefined || + first.sessionId !== providerThread.nativeThreadRef?.nativeId + ) + return null; + return wakeSelections.get(first) ?? null; + }), + hasBufferedOutputForThread: (providerThread) => + Effect.gen(function* () { + const sessionId = yield* Ref.get(activeSessionId); + return ( + sessionId !== null && + providerThread.nativeThreadRef?.nativeId === sessionId && + (yield* Ref.get(wakeBuffer)).length > 0 + ); + }), hasPendingBackgroundWork: Effect.gen(function* () { if ((yield* Ref.get(wakeBuffer)).length > 0) return true; if (yield* Ref.get(continuationRequested)) return true; @@ -7384,6 +7619,20 @@ export function makeAcpAdapterV2( teardownState: (yield* Ref.get(runtimeTeardownState))._tag, }, ); + if ( + context === null && + !containOrphanRuntime && + turnInput.requestRuntimeRestart === true && + turnInput.providerThread.nativeThreadRef?.nativeId === + (yield* Ref.get(activeSessionId)) + ) { + yield* runtimeCallbackPermit.withPermit( + Effect.gen(function* () { + yield* awaitAdmittedNativeResponses; + yield* quarantineStoppedRun(); + }), + ); + } if (containOrphanRuntime) { const teardownBarrier = yield* Deferred.make< void, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAppliedSelection.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAppliedSelection.test.ts new file mode 100644 index 000000000000..9c7c6639dcac --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/AcpAppliedSelection.test.ts @@ -0,0 +1,42 @@ +import { assert, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { acpInitialAppliedSelection } from "./AcpAdapterV2.ts"; + +const requested = { instanceId: ProviderInstanceId.make("acp-seed"), model: "requested-model" }; + +it("does not invent an applied model before an unacknowledged model step", () => { + assert.deepEqual(acpInitialAppliedSelection({}, requested, undefined, true), { + instanceId: requested.instanceId, + }); +}); + +it("keeps the requested label when the agent exposes no model step", () => { + assert.equal(acpInitialAppliedSelection({}, requested, undefined, false).model, requested.model); +}); + +it("seeds native-reported model and option values rather than requested values", () => { + assert.deepEqual( + acpInitialAppliedSelection( + { + models: { currentModelId: "native-model", availableModels: [] }, + configOptions: [ + { + id: "effort", + name: "Effort", + type: "select", + currentValue: "low", + options: [{ value: "low", name: "Low" }], + }, + ], + }, + { ...requested, options: [{ id: "effort", value: "high" }] }, + undefined, + true, + ), + { + instanceId: requested.instanceId, + model: "native-model", + options: [{ id: "effort", value: "low" }], + }, + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 29c96447e9f4..db5309f4159e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3886,8 +3886,39 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadA)); assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadB)); - // Starting B closes A's only live query. A can never emit a roster - // clear from a dead process, so openQuery must idle-clear A. + const blockedSibling = yield* Effect.exit( + runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: appThreadB, + providerThread: providerThreadB, + now, + attemptId: RunAttemptId.make("attempt-roster-blocked-b"), + text: "Do not kill A.", + attachments: [], + }), + ), + ); + assert.isTrue(Exit.isFailure(blockedSibling)); + assert.equal(processQueues.length, 1); + assert.equal(processA.queue.state._tag, "Open"); + assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadA)); + yield* Queue.offer( + processA.queue, + claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000000310", + session_id: nativeIds[0], + }), + ); + yield* awaitUntil( + () => + providerThreadRosterEvents(events).at(-1)?.providerThread.pendingBackgroundTasks + ?.length === 0, + "A task finished", + ); + // Once A finishes, B may replace the idle process. yield* runtime.startTurn( makeClaudeTestTurnInput({ threadId: appThreadB, @@ -6572,8 +6603,40 @@ describe("ClaudeAdapterV2 background wake turns", () => { ...CLAUDE_TEST_MODEL_SELECTION, model: "claude-haiku-4-5-20251001", } satisfies ModelSelection; - // ProviderTurnStartService marks the thread active before startTurn; - // the process-reset clear must preserve that status. + const blockedChange = yield* Effect.exit( + runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + modelSelection: alternateModel, + attemptId: RunAttemptId.make("attempt-process-reset-blocked"), + text: "Do not kill the task.", + attachments: [], + providerTurnOrdinal: 2, + }), + ), + ); + assert.isTrue(Exit.isFailure(blockedChange)); + assert.equal(processQueues.length, 1); + assert.equal(firstProcess.state._tag, "Open"); + yield* Queue.offer( + firstProcess, + claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000000610", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => + providerThreadRosterEvents(events).at(-1)?.providerThread.pendingBackgroundTasks + ?.length === 0, + "task finished before replace", + ); + // The process-reset clear preserves the new turn's active status. const activeProviderThread = { ...providerThread, status: "active" as const, @@ -6592,33 +6655,22 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.equal(processQueues.length, 2); - // Process-scoped level resets to empty on CLI (re)start while the - // starting turn's provider thread remains active (not idle). + // Completion already emptied the roster. The reset need not emit a + // duplicate empty level; the replacement must still start and reset + // eligibility, without making completed work pending again. yield* awaitUntil( () => - providerThreadRosterEvents(events).some( + events.filter( (event) => - event.providerThread.status === "active" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0 && - // Prefer the post-replace clear over the initial empty thread. - event.providerThread.updatedAt !== undefined, - ), - "roster cleared on process replace while remaining active", + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ).length === 2, + "replacement running", ); - // After replace, the in-memory Waiting probe must be false even if a - // late empty-level event was already present before background work. assert.isFalse(yield* hasPendingBackgroundWork); - const emptyActiveRosterEvents = providerThreadRosterEvents(events).filter( - (event) => - event.providerThread.status === "active" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, - ); - assert.isAtLeast(emptyActiveRosterEvents.length, 1); assert.deepEqual( - emptyActiveRosterEvents.at(-1)?.providerThread.pendingBackgroundTasks ?? [], + providerThreadRosterEvents(events).at(-1)?.providerThread.pendingBackgroundTasks ?? [], [], ); - assert.equal(emptyActiveRosterEvents.at(-1)?.providerThread.status, "active"); // A late notification from the previous process must not wake after // eligibility was reset with the process. Offer on the new process @@ -6830,7 +6882,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); it.effect( - "does not opaque-misclassify a buffered subagent notification across model/policy query replacement", + "retains subagent classification through blocked replacement and native-buffer drain", () => Effect.scoped( Effect.gen(function* () { @@ -6993,43 +7045,31 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* awaitUntil(() => continuationRequests.length === 1, "continuation after notify"); assert.equal(continuationRequests[0]?.detail, SUBAGENT_SUMMARY); - // Model-changing user turn replaces the query while continuation stays - // queued. Process reset must not invent opaque classification for the - // buffered subagent notification. + // A queued native completion must drain on its original process. const alternateModel = { ...CLAUDE_TEST_MODEL_SELECTION, model: "claude-haiku-4-5-20251001", } satisfies ModelSelection; - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-user"), - text: "Switch model while the subagent completes.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: alternateModel, - }), - ); - assert.equal(processQueues.length, 2); - const secondProcess = processQueues[1]!; - yield* Queue.offer( - secondProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000805", - result: "User turn finished after model switch.", - }), - ); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 2, - "user turn terminal after replace", + const blocked = yield* Effect.exit( + runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-user"), + text: "Switch model while the subagent completes.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, + }), + ), ); - assert.isTrue(yield* hasPendingBackgroundWork); + assert.isTrue(Exit.isFailure(blocked)); + assert.equal(processQueues.length, 1); + assert.equal(firstProcess.state._tag, "Open"); assert.lengthOf(continuationRequests, 1); - yield* Queue.offer( - secondProcess, + firstProcess, makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000806", result: "The subagent finished with SUB_BUFFER_REPLACE_DONE.", @@ -7044,13 +7084,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { text: "Background task completed.", attachments: [], providerTurnOrdinal: 3, - modelSelection: alternateModel, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, messageCreatedBy: "agent", messageCreationSource: "provider", }), ); yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 3, + () => events.filter((event) => event.type === "turn.terminal").length === 2, "continuation terminal after buffered subagent drain", ); @@ -7066,6 +7106,20 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.equal(subagentNodeEvents.at(-1)?.node.status, "completed"); assert.isFalse(yield* hasPendingBackgroundWork); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + modelSelection: alternateModel, + attemptId: RunAttemptId.make("attempt-subagent-explicit-retry"), + text: "Explicit retry after drain.", + attachments: [], + providerTurnOrdinal: 4, + }), + ); + assert.equal(processQueues.length, 2); + assert.equal(firstProcess.state._tag, "Done"); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), ); @@ -7080,6 +7134,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-v2-replace-open-fail-", }); + const closing = yield* Deferred.make(); + const releaseClose = yield* Deferred.make(); let openCount = 0; const processQueues: Array> = []; const events: Array = []; @@ -7114,7 +7170,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, interrupt: Effect.void, - close: Queue.shutdown(sdkMessages), + close: Deferred.succeed(closing, undefined).pipe( + Effect.andThen(Deferred.await(releaseClose)), + Effect.andThen(Queue.shutdown(sdkMessages)), + ), }; }); }, @@ -7189,7 +7248,51 @@ describe("ClaudeAdapterV2 background wake turns", () => { ) .pipe(Effect.exit); assert.isTrue(Exit.isFailure(failedStart)); - // Old process was closed before the failed open: roster must not stick. + assert.equal(openCount, 1, "running-work guard precedes the forced open failure"); + assert.equal(processQueues[0]!.state._tag, "Open"); + assert.isTrue(yield* hasPendingBackgroundWork); + yield* Queue.offer( + processQueues[0]!, + claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000000910", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => + providerThreadRosterEvents(events).at(-1)?.providerThread.pendingBackgroundTasks + ?.length === 0, + "task finished before failed open", + ); + const replacing = yield* runtime + .startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + modelSelection: alternateModel, + attemptId: RunAttemptId.make("attempt-replace-after-finish"), + text: "Retry explicitly after completion.", + attachments: [], + providerTurnOrdinal: 3, + }), + ) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(closing); + const closingSelection = yield* runtime.liveExecutionSelection!(providerThread); + yield* Deferred.succeed(releaseClose, undefined); + const failedOpen = yield* Fiber.join(replacing); + assert.isNull( + closingSelection, + "a closing query is not live before its stream finalizer", + ); + assert.isTrue(Exit.isFailure(failedOpen)); + assert.equal(openCount, 2); + assert.equal(processQueues[0]!.state._tag, "Done"); + // A safe replacement that fails after closing still clears process state. yield* awaitUntil( () => providerThreadRosterEvents(events).some( @@ -7331,7 +7434,50 @@ describe("ClaudeAdapterV2 background wake turns", () => { ) .pipe(Effect.exit); assert.isTrue(Exit.isFailure(failedStart)); + assert.equal(openCount, 1, "buffer guard prevents closing the owner"); + assert.isTrue(yield* hasPendingBackgroundWork); + assert.equal(processQueues[0]!.state._tag, "Open"); + yield* Queue.offer(processQueues[0]!, wakeResult); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-failed-replace-drain"), + text: "Drain, never resend.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === 2, + "retained wake drained", + ); + assert.isTrue( + events.some( + (event) => + event.type === "message.updated" && event.message.text === WAKE_ASSISTANT_TEXT, + ), + ); assert.isFalse(yield* hasPendingBackgroundWork); + const failedOpen = yield* Effect.exit( + runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + modelSelection: alternateModel, + attemptId: RunAttemptId.make("attempt-failed-replace-after-drain"), + text: "Explicit replacement after drain.", + attachments: [], + providerTurnOrdinal: 3, + }), + ), + ); + assert.isTrue(Exit.isFailure(failedOpen)); + assert.equal(openCount, 2); yield* runtime.startTurn( makeClaudeTestTurnInput({ @@ -7369,7 +7515,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 2, + () => events.filter((event) => event.type === "turn.terminal").length === 3, "retry turn terminal", ); yield* Queue.offer( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f6346b05d245..5dca2667d76e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -604,6 +604,19 @@ export const claudeAgentSdkQueryRunnerLiveLayer: Layer.Layer< }), catch: (cause) => queryRunnerError(cause, "query"), }); + yield* Effect.tryPromise({ + try: () => queryRuntime.initializationResult(), + catch: (cause) => queryRunnerError(cause, "initialize"), + }).pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? Queue.shutdown(promptQueue).pipe( + Effect.andThen(closeClaudeQuery(queryRuntime)), + Effect.ignore, + ) + : Effect.void, + ), + ); yield* logProtocolEvent({ direction: "outgoing", stage: "decoded", @@ -2800,9 +2813,15 @@ export function makeClaudeAdapterV2( }); const events = yield* Queue.unbounded(); const activeTurn = yield* Ref.make(null); + const lastTurn = yield* Ref.make(null); const interruptedTurns = yield* Ref.make(new Set()); const steeredTurns = yield* Ref.make(new Set()); const queryContext = yield* Ref.make(null); + const stopRequestedQueries = new WeakSet(); + const acknowledgedQueryTurn = yield* Ref.make<{ + readonly query: ClaudeLiveQueryContext; + readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; + } | null>(null); const openedNativeThreads = yield* Ref.make(new Set()); const latestPlanByKind = yield* Ref.make(new Map()); const planIdsByNativeItem = yield* Ref.make( @@ -2870,7 +2889,14 @@ export function makeClaudeAdapterV2( const wakeBuffers = yield* Ref.make( new Map< string, - { readonly messages: ReadonlyArray; readonly detail: string | null } + { + readonly messages: ReadonlyArray; + readonly detail: string | null; + readonly producer: { + readonly query: ClaudeLiveQueryContext; + readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; + } | null; + } >(), ); // Background work that ended and has not been named by a wake offer yet, @@ -4677,6 +4703,22 @@ export function makeClaudeAdapterV2( const nativeThreadId = input.context.input.providerThread.nativeThreadRef?.nativeId ?? null; if (nativeThreadId !== null) { + const buffered = (yield* Ref.get(wakeBuffers)).get(nativeThreadId); + const live = yield* Ref.get(queryContext); + if ( + input.status === "completed" && + buffered?.producer != null && + buffered.producer.query === live && + buffered.producer.input.runOrdinal < input.context.input.runOrdinal && + buffered.messages.every( + (message) => + message.type === "system" && message.subtype === "task_notification", + ) && + !(yield* hasOwnedBufferedOutput(nativeThreadId)) + ) { + // The later turn on this query consumed its queued opaque notification. + yield* clearWakeStateForNativeThread(nativeThreadId); + } if (input.status !== "completed") { yield* clearPendingBackgroundTasksForNativeThread(nativeThreadId); yield* clearNativeThreadTaskIdSet( @@ -4932,12 +4974,16 @@ export function makeClaudeAdapterV2( childThreadId: registered?.childThreadId, }); } + const acknowledged = yield* Ref.get(acknowledgedQueryTurn); + const live = yield* Ref.get(queryContext); + const producer = acknowledged?.query === live ? acknowledged : null; yield* Ref.update(wakeBuffers, (current) => { const existing = current.get(wakeInput.nativeThreadId); const updated = new Map(current); updated.set(wakeInput.nativeThreadId, { messages: [...(existing?.messages ?? []), message], detail: notificationSummary ?? existing?.detail ?? null, + producer: existing?.producer ?? producer, }); return updated; }); @@ -5120,8 +5166,9 @@ export function makeClaudeAdapterV2( // A held subagent frame replayed after its owner registered. Its // turn-level assistant bookkeeping already ran when it arrived. readonly replayed?: boolean; + readonly replayContext?: ClaudeLiveQueryContext; }) { - const liveQuery = yield* Ref.get(queryContext); + const liveQuery = input.replayContext ?? (yield* Ref.get(queryContext)); if (liveQuery?.query !== input.query) { return; } @@ -6064,18 +6111,26 @@ export function makeClaudeAdapterV2( const handleRoutedSdkMessage = Effect.fnUntraced(function* (input: { readonly query: ClaudeAgentSdkQuerySession; readonly message: SDKMessage; + readonly replayContext?: ClaudeLiveQueryContext; }) { + const held = (message: SDKMessage) => + handleSdkMessageFrame({ + query: input.query, + message, + replayed: true, + ...(input.replayContext === undefined ? {} : { replayContext: input.replayContext }), + }); // Progress or a notification can be the first frame naming a known // subagent's tool_use_id; its held frames must precede the result. if (input.message.type !== "system" || input.message.subtype !== "task_started") { for (const message of yield* takeReleasableSubagentFrames(input.message)) { - yield* handleSdkMessageFrame({ query: input.query, message, replayed: true }); + yield* held(message); } } yield* handleSdkMessageFrame(input); // task_started registers its subagent while being handled. for (const message of yield* takeReleasableSubagentFrames(input.message)) { - yield* handleSdkMessageFrame({ query: input.query, message, replayed: true }); + yield* held(message); } }); @@ -6118,10 +6173,11 @@ export function makeClaudeAdapterV2( const handleSdkMessage = Effect.fnUntraced(function* (input: { readonly query: ClaudeAgentSdkQuerySession; readonly message: SDKMessage; + readonly replayContext?: ClaudeLiveQueryContext; }) { const message = input.message; const context = yield* Ref.get(activeTurn); - const liveQuery = yield* Ref.get(queryContext); + const liveQuery = input.replayContext ?? (yield* Ref.get(queryContext)); if ( context === null || context.promptUuid === null || @@ -6496,6 +6552,22 @@ export function makeClaudeAdapterV2( }), ); + const hasOwnedBufferedOutput = Effect.fnUntraced(function* (nativeThreadId: string) { + const buffered = (yield* Ref.get(wakeBuffers)).get(nativeThreadId); + const subagents = yield* Ref.get(sessionSubagentsByTaskId); + return ( + buffered?.messages.some( + (message) => + message.type === "user" || + message.type === "assistant" || + message.type === "result" || + (message.type === "system" && + message.subtype === "task_notification" && + subagents.has(message.task_id)), + ) === true + ); + }); + const openQuery = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, nativeThreadId: string, @@ -6522,6 +6594,28 @@ export function makeClaudeAdapterV2( return existing; } + // A process the user explicitly stopped may be replaced while it exits. + if (existing !== null && !stopRequestedQueries.has(existing)) { + if (yield* hasOwnedBufferedOutput(existing.nativeThreadId)) { + return yield* new ProviderAdapter.ProviderAdapterBufferedOutputError({ + driver: CLAUDE_PROVIDER, + providerThreadId: turnInput.providerThread.id, + }); + } + const roster = rosterForNativeThread( + yield* Ref.get(pendingBackgroundTasksByNativeThread), + existing.nativeThreadId, + ); + const runningSubagents = [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].some( + (entry) => entry.task.status === "running", + ); + if (roster.size > 0 || runningSubagents) { + return yield* new ProviderAdapter.ProviderAdapterRunningWorkError({ + driver: CLAUDE_PROVIDER, + }); + } + } + // openQuery owns one live process. Closing it for another native // thread kills that sibling's CLI; it can never emit a roster clear, // so drop its process-scoped Waiting/wake state immediately. Closing @@ -6529,6 +6623,7 @@ export function makeClaudeAdapterV2( // the replacement open succeeds or fails below. const closedExistingNativeThreadId = existing !== null ? existing.nativeThreadId : null; if (existing !== null) { + yield* Ref.update(queryContext, (current) => (current === existing ? null : current)); yield* existing.query.close.pipe(Effect.ignore); if (existing.nativeThreadId !== nativeThreadId) { yield* clearWakeStateForNativeThread(existing.nativeThreadId); @@ -6735,7 +6830,11 @@ export function makeClaudeAdapterV2( skillNames: yield* userInvocableSkillNames(turnInput.runtimePolicy.cwd), uuid: claudePromptUuid(turnInput.attemptId), }); - const querySession = yield* openQuery(turnInput, nativeThreadId); + const buffered = (yield* Ref.get(wakeBuffers)).get(nativeThreadId); + const querySession = + isContinuationTurn && buffered?.producer != null + ? buffered.producer.query + : yield* openQuery(turnInput, nativeThreadId); yield* Ref.set(activeTurn, context); yield* emitProviderEvent({ type: "provider_turn.updated", @@ -6753,8 +6852,11 @@ export function makeClaudeAdapterV2( // Counted only here, so a turn that failed to start does not age reports. yield* startUserTurnForWakeReports(nativeThreadId); yield* querySession.query.offer(userMessage); + yield* Ref.set(acknowledgedQueryTurn, { query: querySession, input: turnInput }); + yield* Ref.set(lastTurn, context); return; } + yield* Ref.set(lastTurn, context); const drained = yield* Ref.modify(wakeBuffers, (current) => { const entry = current.get(nativeThreadId); if (entry === undefined) { @@ -6793,12 +6895,20 @@ export function makeClaudeAdapterV2( ); for (const entry of drained) { if (entry.type !== "result") { - yield* handleSdkMessage({ query: querySession.query, message: entry }); + yield* handleSdkMessage({ + query: querySession.query, + message: entry, + replayContext: querySession, + }); } } const lastResult = resultMessages.at(-1); if (lastResult !== undefined) { - yield* handleSdkMessage({ query: querySession.query, message: lastResult }); + yield* handleSdkMessage({ + query: querySession.query, + message: lastResult, + replayContext: querySession, + }); return; } const hasNativeWakeFrame = drained.some( @@ -6811,6 +6921,13 @@ export function makeClaudeAdapterV2( }, (effect, turnInput) => effect.pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? Ref.update(activeTurn, (current) => + current?.input.attemptId === turnInput.attemptId ? null : current, + ) + : Effect.void, + ), Effect.mapError( (cause) => new ProviderAdapter.ProviderAdapterTurnStartError({ @@ -6843,11 +6960,20 @@ export function makeClaudeAdapterV2( ) { // Stop after the turn settled: the background shells belong to // the CLI process, so closing its query is what stops them. + stopRequestedQueries.add(existing); yield* closeLiveQueryForNativeThread(nativeThreadId); // A turn started while the close was pending may have opened a // replacement process. Its Waiting and wake state are its own. const current = yield* Ref.get(queryContext); if (current === null || current.query === existing.query) { + const context = yield* Ref.get(lastTurn); + if (context !== null) { + for (const [taskId, entry] of yield* Ref.get(sessionSubagentsByTaskId)) { + if (entry.task.status === "running") + yield* updateClaudeSubagentNode({ context, taskId, status: "cancelled" }); + } + } + yield* Ref.set(lastTurn, null); yield* clearWakeStateForNativeThread(nativeThreadId); yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, { status: "idle", @@ -7029,15 +7155,45 @@ export function makeClaudeAdapterV2( } return false; }), + liveExecutionSelection: (providerThread) => + Effect.gen(function* () { + const acknowledged = yield* Ref.get(acknowledgedQueryTurn); + const live = yield* Ref.get(queryContext); + return live !== null && + acknowledged?.query === live && + acknowledged.input.providerThread.id === providerThread.id && + live.nativeThreadId === providerThread.nativeThreadRef?.nativeId + ? acknowledged.input.modelSelection + : null; + }), + bufferedExecutionSelection: (providerThread) => + Effect.gen(function* () { + const nativeId = providerThread.nativeThreadRef?.nativeId; + const producer = + nativeId == null ? null : (yield* Ref.get(wakeBuffers)).get(nativeId)?.producer; + return producer?.input.providerThread.id === providerThread.id + ? producer.input.modelSelection + : null; + }), + hasBufferedOutputForThread: (providerThread) => + Effect.gen(function* () { + const nativeThreadId = providerThread.nativeThreadRef?.nativeId; + return nativeThreadId != null && (yield* hasOwnedBufferedOutput(nativeThreadId)); + }), hasPendingBackgroundWorkForThread: (providerThread) => Effect.gen(function* () { const nativeThreadId = providerThread.nativeThreadRef?.nativeId; if (nativeThreadId === undefined || nativeThreadId === null) { return false; } - // Root-run stop gate: only this native thread's roster. Session - // subagents and wake buffers stay on the session-wide probe. + const context = yield* Ref.get(lastTurn); + const runningSubagents = + context?.input.providerThread.nativeThreadRef?.nativeId === nativeThreadId && + [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].some( + (entry) => entry.task.status === "running", + ); return ( + runningSubagents || rosterForNativeThread( yield* Ref.get(pendingBackgroundTasksByNativeThread), nativeThreadId, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeInitialization.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeInitialization.test.ts new file mode 100644 index 000000000000..e2359b6635de --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeInitialization.test.ts @@ -0,0 +1,109 @@ +import type { Query } from "@anthropic-ai/claude-agent-sdk"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../../provider/Layers/ProviderEventLoggers.ts"; +import { + ClaudeAgentSdkQueryRunner, + claudeAgentSdkQueryRunnerLiveLayer, +} from "./ClaudeAdapterV2.ts"; + +const mock = vi.hoisted(() => ({ query: vi.fn() })); +vi.mock("@anthropic-ai/claude-agent-sdk", async (load) => ({ + ...(await load()), + query: mock.query, +})); + +for (const outcome of ["success", "failure", "interruption"] as const) { + it.effect(`Claude runner acknowledges initialization and cleans up ${outcome}`, () => + Effect.scoped( + Effect.gen(function* () { + const initialize = Promise.withResolvers(); + const initializing = Promise.withResolvers(); + let closed = 0; + let consumed = 0; + let completedOpen = false; + mock.query.mockImplementation(() => { + initializing.resolve(); + return { + initializationResult: () => initialize.promise, + close: () => { + closed++; + }, + next: () => { + consumed++; + return Promise.resolve({ done: true, value: undefined }); + }, + return: () => Promise.resolve({ done: true, value: undefined }), + [Symbol.asyncIterator]() { + return this; + }, + } as unknown as Query; + }); + const runner = yield* ClaudeAgentSdkQueryRunner; + const opening = yield* runner + .open({ + threadId: ThreadId.make("initialization"), + providerSessionId: ProviderSessionId.make("initialization"), + options: { + sessionId: "initialization", + model: "claude-sonnet-4-6", + tools: [], + permissionMode: "default", + effort: "high", + }, + }) + .pipe( + Effect.tap(() => + Effect.sync(() => { + completedOpen = true; + }), + ), + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.promise(() => initializing.promise); + assert.isFalse(completedOpen, "scheduling initialization is not acknowledgment"); + assert.equal( + consumed, + 0, + "initialization acknowledgment does not depend on event consumption", + ); + if (outcome === "success") { + initialize.resolve({ commands: [], models: [] }); + const session = yield* Fiber.join(opening); + assert.isTrue(completedOpen); + assert.equal(closed, 0); + yield* session.close; + } else if (outcome === "failure") { + initialize.reject(new Error("initialization rejected")); + assert.isTrue(Exit.isFailure(yield* Fiber.await(opening))); + assert.isFalse(completedOpen); + } else { + yield* Fiber.interrupt(opening); + assert.isFalse(completedOpen); + } + assert.equal(closed, 1); + mock.query.mockReset(); + }), + ).pipe( + Effect.provide( + claudeAgentSdkQueryRunnerLiveLayer.pipe( + Layer.provide( + Layer.merge( + NodeServices.layer, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ), + ), + ), + ), + ), + ); +} diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 7ceecc4f487f..c52fd6843569 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -4233,6 +4233,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi providerThreadId: context.providerThread.id, driver: CODEX_PROVIDER, detail: codexBackgroundCommandDetail(payload.item), + delivery: "message_text", notification: { ...backgroundWorkNotification([ { @@ -5337,6 +5338,15 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi ); const runtime: ProviderAdapterV2SessionRuntime = { + preservesSelectionOnSameSelectionFailure: true, + reappliesFullSelection: (selection) => + Effect.succeed( + getModelSelectionStringOptionValue(selection, "reasoningEffort") !== undefined && + getCodexServiceTierOptionValue(selection) !== undefined && + (selection.options ?? []).every((option) => + ["reasoningEffort", "serviceTier", "fastMode"].includes(option.id), + ), + ), instanceId: adapterOptions.instanceId, driver: CODEX_PROVIDER, providerSessionId: input.providerSessionId, diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts index c7f6f26d89fa..6659fbf495f5 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts @@ -254,21 +254,24 @@ export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdap supportsImagePrompts: true, supportsCompaction: true, resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model), - applyModelSelection: ({ runtime, startResult, modelSelection }) => + canForceModelSelection: true, + applyModelSelection: ({ runtime, startResult, modelSelection, force }) => Effect.gen(function* () { const legacy = startResult.initializeResult.protocolVersion === 1; const options = legacy ? [] : yield* runtime.getConfigOptions; const configuredModel = options.find((option) => option.category === "model")?.currentValue; + let currentModelId: string | undefined; + if (legacy) + currentModelId = currentGrokModelIdFromSessionSetup(startResult.sessionSetupResult); + else if (typeof configuredModel === "string") currentModelId = configuredModel; + const requestedModelId = resolveGrokAcpBaseModelId(modelSelection.model); + if (force === true && requestedModelId !== "grok-build") currentModelId = undefined; return yield* applyGrokAcpModelSelection({ runtime: legacy ? runtime - : { setSessionModel: (model) => runtime.setModel(model).pipe(Effect.as({})) }, - currentModelId: legacy - ? currentGrokModelIdFromSessionSetup(startResult.sessionSetupResult) - : typeof configuredModel === "string" - ? configuredModel - : undefined, - requestedModelId: resolveGrokAcpBaseModelId(modelSelection.model), + : { setSessionModel: (model) => runtime.setModel(model, force).pipe(Effect.as({})) }, + currentModelId, + requestedModelId, mapError: (cause) => cause, }); }), diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index 3a3f9a2af6cb..6b3319a1b98f 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -237,6 +237,57 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }); describe("OpenCodeAdapterV2", () => { + for (const reply of ["success", "failure"] as const) { + it.effect(`does not acknowledge selection when Stop wins prompt admission: ${reply}`, () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const called = promiseGate(); + const harness = yield* makeOpenCodeRuntimeHarness(`cancel-admission-${reply}`, "root", { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async (_input: unknown, options: { signal: AbortSignal }) => { + called.resolve(); + return new Promise((resolve, reject) => { + options.signal.addEventListener( + "abort", + () => + reply === "success" ? resolve({ data: true }) : reject(new Error("cancelled")), + { once: true }, + ); + }); + }, + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + const running = yield* harness.runtime.events.pipe( + Stream.filter( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const starting = yield* harness + .startTurn() + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => called.promise); + const providerTurn = Option.getOrThrow(yield* Fiber.join(running)); + if (providerTurn.type !== "provider_turn.updated") + return yield* Effect.die("Expected running provider turn"); + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: providerTurn.providerTurn.id, + }); + assert.isTrue( + Exit.isFailure(yield* Fiber.join(starting)), + "cancelled admission cannot certify the requested selection", + ); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + } + for (const ending of ["completed", "failed", "unresolved", "unavailable", "reconnect"] as const) { it.effect(`normalizes OpenCode step usage for ${ending} turns`, () => Effect.gen(function* () { @@ -1560,7 +1611,7 @@ describe("OpenCodeAdapterV2", () => { modelSelection, runtimePolicy: policy, }) - .pipe(Effect.forkScoped); + .pipe(Effect.exit, Effect.forkScoped); yield* Effect.promise(() => promptStarted.promise); const running = yield* runtime.readThreadSnapshot({ providerThread }); @@ -1570,7 +1621,7 @@ describe("OpenCodeAdapterV2", () => { .pipe(Effect.forkScoped); yield* Effect.promise(() => abortCalled.promise); - yield* Fiber.join(start); + assert.isTrue(Exit.isFailure(yield* Fiber.join(start))); yield* Fiber.join(interrupt); yield* Effect.promise(() => nativeEvents.push({ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index 98203a7f91af..c035d3fefa2f 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -2989,6 +2989,15 @@ export function makeOpenCodeAdapterV2( const runtimeSession: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: options.instanceId, + preservesSelectionOnSameSelectionFailure: true, + reappliesFullSelection: (selection) => + Effect.succeed( + getModelSelectionStringOptionValue(selection, "agent") !== undefined && + getModelSelectionStringOptionValue(selection, "variant") !== undefined && + (selection.options ?? []).every((option) => + ["agent", "variant"].includes(option.id), + ), + ), driver: OPENCODE_PROVIDER, providerSessionId: input.providerSessionId, providerSession: sessionEntity, @@ -3256,7 +3265,7 @@ export function makeOpenCodeAdapterV2( }), ), Effect.catch((cause) => - admissionAbortController!.signal.aborted ? Effect.void : Effect.fail(cause), + admissionAbortController!.signal.aborted ? Effect.interrupt : Effect.fail(cause), ), Effect.ensuring( Effect.all([ @@ -3269,6 +3278,7 @@ export function makeOpenCodeAdapterV2( ]).pipe(Effect.asVoid), ), ); + if (admissionAbortController?.signal.aborted) return yield* Effect.interrupt; if (state.activeTurn === turn && !turn.finalized && !turn.interrupted) { const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted"); if (admissionAction === "reconcile-idle") { diff --git a/apps/server/src/orchestration-v2/BufferPolicyClaude.integration.test.ts b/apps/server/src/orchestration-v2/BufferPolicyClaude.integration.test.ts new file mode 100644 index 000000000000..fa903b328d30 --- /dev/null +++ b/apps/server/src/orchestration-v2/BufferPolicyClaude.integration.test.ts @@ -0,0 +1,441 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + ClaudeSettings, + CommandId, + MessageId, + ProjectId, + ThreadId, + type ModelSelection, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { CLAUDE_DEFAULT_INSTANCE_ID, makeClaudeAdapterV2 } from "./Adapters/ClaudeAdapterV2.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { layer as idAllocatorLayer, IdAllocatorV2 } from "./IdAllocator.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import { + ProviderContinuationRequests, + type ProviderContinuationRequest, +} from "./ProviderContinuationRequests.ts"; +import { workerLive } from "./ProviderContinuationService.ts"; +import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const NATIVE = "native-buffer-policy-claude"; +const A: ModelSelection = { + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-sonnet-4-6", + options: [{ id: "effort", value: "high" }], +}; +const B: ModelSelection = { ...A, options: [{ id: "effort", value: "low" }] }; +const settings = Schema.decodeSync(ClaudeSettings)({}); +const frame = (value: unknown) => value as SDKMessage; +const result = (id: number, text: string, wake = false) => + frame({ + type: "result", + subtype: "success", + duration_ms: 10, + duration_api_ms: 10, + is_error: false, + num_turns: 1, + result: text, + stop_reason: "end_turn", + total_cost_usd: 0, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: `00000000-0000-4000-8000-${String(id).padStart(12, "0")}`, + session_id: NATIVE, + ...(wake ? { origin: { kind: "task-notification" } } : {}), + }); +const started = frame({ + type: "system", + subtype: "task_started", + task_id: "build", + description: "npm run build", + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000000001", + session_id: NATIVE, +}); +const finished = frame({ + type: "system", + subtype: "task_notification", + task_id: "build", + status: "completed", + output_file: "/tmp/build.log", + summary: "Build finished", + uuid: "00000000-0000-4000-8000-000000000003", + session_id: NATIVE, +}); +const assistant = frame({ + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: "The build has finished." }] }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000004", + session_id: NATIVE, +}); + +for (const scenario of [ + "idle-wake", + "queued-wake", + "running-replacement", + "finish-recovery", + "stop-recovery", + "subagent-stop", + "uncertain-buffer", +] as const) { + it.effect(`real Claude buffer policy: ${scenario}`, () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`buffer-policy-${scenario}`); + const sdk = yield* Queue.unbounded(); + const offers = yield* Queue.unbounded(); + const threadId = ThreadId.make(`thread:buffer-policy:${scenario}`); + let opens = 0; + let closes = 0; + let prompts = 0; + const adapter = yield* Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings, + environment: {}, + fileSystem, + attachmentsDir: yield* fileSystem.makeTempDirectoryScoped({ + prefix: "buffer-policy-claude-", + }), + path: yield* Path.Path, + idAllocator: yield* IdAllocatorV2, + continuationRequests: { + offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), + }, + queryRunner: { + allocateSessionId: Effect.succeed(NATIVE), + open: () => + Effect.gen(function* () { + const messages = opens === 0 ? sdk : yield* Queue.unbounded(); + opens++; + return { + messages: Stream.fromQueue(messages), + offer: () => + Effect.sync(() => { + prompts++; + }), + setModel: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(messages).pipe( + Effect.andThen( + Effect.sync(() => { + closes++; + }), + ), + ), + }; + }), + forkSession: () => Effect.die("unused fork"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const manager = yield* ProviderSessionManagerV2; + const projection = orchestrator.getThreadProjection(threadId); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const send = (name: string, modelSelection?: ModelSelection) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(name), + threadId, + messageId: MessageId.make(`message:${name}`), + text: name, + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + ...(modelSelection === undefined ? {} : { modelSelection }), + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:buffer-policy"), + title: "Buffer policy", + modelSelection: A, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const firstStarted = yield* watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* send("first"); + yield* worker.drain(); + yield* Fiber.join(firstStarted); + const firstTerminal = yield* watch( + (event) => + event.type === "run.updated" && + (event.payload.status === "waiting" || event.payload.status === "completed"), + ); + yield* Queue.offer( + sdk, + scenario === "subagent-stop" + ? frame({ ...started, task_type: "local_agent" }) + : started, + ); + yield* Queue.offer(sdk, result(2, "Kicked off.")); + yield* Fiber.join(firstTerminal); + yield* worker.drain(); + const binding = (yield* projection).providerThreads[0]!; + const runtime = Option.getOrThrow(yield* manager.get(binding.providerSessionId!)); + assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(binding)); + assert.isTrue(yield* runtime.hasPendingBackgroundWork!); + if (scenario !== "idle-wake" && scenario !== "queued-wake") { + const failed = yield* watch( + (event) => event.type === "run.updated" && event.payload.status === "failed", + ); + const dispatch = yield* Effect.exit(send("change", B)); + // The command may save future settings; the actual query boundary owns the veto. + assert.equal(dispatch._tag, "Success"); + yield* worker.drain(); + yield* Fiber.join(failed); + assert.equal(opens, 1); + assert.equal(closes, 0); + assert.equal(prompts, 1); + assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(binding)); + assert.isTrue(yield* runtime.hasPendingBackgroundWork!); + const current = yield* projection; + assert.equal(current.runs[1]?.status, "failed"); + assert.deepEqual(current.thread.modelSelection, B); + const errors = current.turnItems.filter((item) => item.type === "error"); + assert.isTrue( + errors.some( + (item) => item.failure?.message.includes("running background work") === true, + ), + ); + assert.deepEqual(yield* runtime.executionSelection!(binding), A); + assert.equal( + (yield* projection).runs.length, + 2, + "no implicit retry of the failed prompt", + ); + if (scenario === "running-replacement") return; + if (scenario === "uncertain-buffer") { + yield* Queue.offer(sdk, finished); + yield* Queue.offer(sdk, assistant); + yield* Queue.offer(sdk, result(7, "Finished after refusal", true)); + const offered = yield* Queue.take(offers); + const requests = yield* Queue.unbounded(); + const dispatched = yield* Queue.unbounded(); + yield* Layer.build(workerLive.pipe(EffectLayer(orchestrator, requests, dispatched))); + const delivered = yield* watch( + (event) => + event.type === "turn-item.updated" && + event.payload.type === "assistant_message" && + event.payload.text === "The build has finished.", + ); + yield* Queue.offer(requests, offered); + assert.isTrue( + yield* Queue.take(dispatched), + "a refused replacement leaves the producing query known", + ); + yield* worker.drain(); + assert.notEqual((yield* projection).runs.at(-1)?.status, "failed"); + yield* Fiber.join(delivered); + assert.isFalse(yield* runtime.hasBufferedOutputForThread!(binding)); + assert.equal(closes, 0); + assert.equal(prompts, 1); + return; + } + if (scenario === "stop-recovery" || scenario === "subagent-stop") { + const stoppedSubagent = + scenario === "subagent-stop" + ? yield* watch( + (event) => + event.type === "subagent.updated" && event.payload.status === "cancelled", + ) + : undefined; + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("explicit-stop"), + threadId, + runId: current.runs[1]!.id, + }); + yield* worker.drain(); + assert.equal(closes, 1, "explicit Stop closes the blocked owner"); + assert.isFalse(yield* runtime.hasPendingBackgroundWork!); + if (stoppedSubagent !== undefined) yield* Fiber.join(stoppedSubagent); + if (scenario === "subagent-stop") { + const tasks = (yield* projection).subagents; + assert.lengthOf(tasks, 1); + assert.equal(tasks[0]?.status, "cancelled"); + assert.equal(tasks[0]?.id, current.subagents[0]?.id); + assert.equal(tasks[0]?.runId, current.subagents[0]?.runId); + } + } else { + const subscription = yield* runtime.subscribeEvents!; + const cleared = yield* subscription.events.pipe( + Stream.filter( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.pendingBackgroundTasks?.length === 0, + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* Queue.offer( + sdk, + frame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000000010", + session_id: NATIVE, + }), + ); + yield* Fiber.join(cleared); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(binding)); + assert.equal(closes, 0, "task finishes in the original process"); + } + const recovered = yield* watch( + (event) => + event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* send("explicit-recovery", B); + yield* worker.drain(); + yield* Fiber.join(recovered); + assert.equal(opens, 2); + assert.equal(prompts, 2, "only the explicit retry is sent"); + const after = yield* projection; + assert.lengthOf(after.runs, 3); + assert.equal(after.runs[1]?.status, "failed"); + assert.equal(after.messages.filter((message) => message.text === "change").length, 1); + assert.equal(after.runs.at(-1)?.status, "running"); + return; + } + yield* Queue.offer(sdk, finished); + yield* Queue.offer(sdk, assistant); + yield* Queue.offer(sdk, result(5, "Wake complete", true)); + const offered = yield* Queue.take(offers); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(binding)); + assert.isTrue(yield* runtime.hasPendingBackgroundWork!); + if (scenario === "queued-wake") { + const active = yield* watch( + (event) => + event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* send("intervening"); + yield* worker.drain(); + yield* Fiber.join(active); + } + const requests = yield* Queue.unbounded(); + const dispatched = yield* Queue.unbounded(); + yield* Layer.build(workerLive.pipe(EffectLayer(orchestrator, requests, dispatched))); + yield* Queue.offer(requests, offered); + assert.isTrue( + yield* Queue.take(dispatched), + "real continuation worker admits completed wake", + ); + const delivered = yield* watch( + (event) => + event.type === "turn-item.updated" && + event.payload.type === "assistant_message" && + event.payload.text === "The build has finished.", + ); + if (scenario === "queued-wake") { + assert.equal((yield* projection).runs.at(-1)?.status, "queued"); + const terminal = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.status === "waiting" && + event.payload.ordinal === 2, + ); + yield* Queue.offer(sdk, result(6, "Intervening complete")); + yield* Fiber.join(terminal); + } + yield* worker.drain(); + assert.notEqual( + (yield* projection).runs.at(-1)?.status, + "failed", + "completed wake starts successfully", + ); + yield* Fiber.join(delivered); + assert.equal(opens, 1); + assert.equal(closes, 0); + assert.equal( + prompts, + scenario === "queued-wake" ? 2 : 1, + "drain never resends the continuation prompt", + ); + assert.isFalse( + yield* runtime.hasBufferedOutputForThread?.(binding) ?? Effect.succeed(true), + ); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: `buffer-policy-${scenario}` }, + makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + }), + ), + ); +} + +function EffectLayer( + orchestrator: OrchestratorV2["Service"], + requests: Queue.Queue, + dispatched: Queue.Queue, +) { + return Layer.provide( + Layer.mergeAll( + idAllocatorLayer, + Layer.succeed(ProviderContinuationRequests, { + offer: (request) => Queue.offer(requests, request).pipe(Effect.asVoid), + take: Queue.take(requests), + }), + Layer.mock(ThreadManagementService)({ + getThreadRecords: orchestrator.getThreadRecords, + dispatch: (command) => + orchestrator + .dispatch(command) + .pipe( + Effect.onExit((exit) => + Queue.offer(dispatched, Exit.isSuccess(exit)).pipe(Effect.asVoid), + ), + ), + }), + ), + ); +} diff --git a/apps/server/src/orchestration-v2/BufferPolicyCodex.integration.test.ts b/apps/server/src/orchestration-v2/BufferPolicyCodex.integration.test.ts new file mode 100644 index 000000000000..059b1b364602 --- /dev/null +++ b/apps/server/src/orchestration-v2/BufferPolicyCodex.integration.test.ts @@ -0,0 +1,378 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CodexSettings, + CommandId, + MessageId, + ProjectId, + ThreadId, + type ModelSelection, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import * as CodexClient from "effect-codex-app-server/client"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stdio from "effect/Stdio"; +import * as Stream from "effect/Stream"; +import { CODEX_DEFAULT_INSTANCE_ID, makeCodexAdapterV2 } from "./Adapters/CodexAdapterV2.ts"; +import { makeReplayServerConfig } from "./Adapters/CodexAdapterV2.testkit.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { layer as idAllocatorLayer, IdAllocatorV2 } from "./IdAllocator.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import { + ProviderContinuationRequests, + type ProviderContinuationRequest, +} from "./ProviderContinuationRequests.ts"; +import { workerLive } from "./ProviderContinuationService.ts"; +import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const A: ModelSelection = { + instanceId: CODEX_DEFAULT_INSTANCE_ID, + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], +}; +const B: ModelSelection = { ...A, options: [{ id: "reasoningEffort", value: "low" }] }; +const NATIVE = "native-buffer-codex"; +const settings = Schema.decodeSync(CodexSettings)({}); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeRequest = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + id: Schema.optional(Schema.Number), + method: Schema.String, + params: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + }), + ), +); +const turn = (id: string, status = "inProgress") => ({ + id, + status, + items: [], + itemsView: "notLoaded", + error: null, + startedAt: 1782622440, + completedAt: status === "completed" ? 1782622450 : null, + durationMs: null, +}); +const commandItem = (status: string) => ({ + type: "commandExecution", + id: "command-background", + command: "sleep 60", + cwd: "/workspace", + processId: "4242", + source: "unifiedExecStartup", + status, + commandActions: [{ type: "unknown", command: "sleep 60" }], + aggregatedOutput: "finished", + exitCode: status === "completed" ? 0 : null, + durationMs: null, +}); + +for (const queued of [false, true]) { + it.effect(`real Codex transport and continuation worker: ${queued ? "queued" : "idle"}`, () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("codex-buffer-policy"); + const inbound = yield* Queue.unbounded(); + const offered = yield* Queue.unbounded(); + const acceptedStarts: Array> = []; + const opens: Array = []; + const encoder = new TextEncoder(); + const emit = (frame: unknown) => + Queue.offer(inbound, encoder.encode(`${encodeJson(frame)}\n`)).pipe(Effect.asVoid); + const notify = (method: string, params: unknown) => emit({ method, params }); + let remainder = ""; + const adapter = yield* Effect.gen(function* () { + const client = yield* CodexClient.make( + Stdio.make({ + args: Effect.succeed([]), + stdin: Stream.fromQueue(inbound), + stderr: () => Sink.drain, + stdout: () => + Sink.forEach((chunk) => + Effect.gen(function* () { + remainder += + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk); + const lines = remainder.split("\n"); + remainder = lines.pop()!; + for (const line of lines.filter(Boolean)) { + const request = yield* decodeRequest(line).pipe(Effect.orDie); + if (request.method === "initialized") continue; + let result: unknown; + if (request.method === "initialize") + result = { + userAgent: "test", + codexHome: "/tmp/codex", + platformFamily: "unix", + platformOs: "linux", + }; + else if ( + request.method === "thread/start" || + request.method === "thread/resume" + ) + result = { + thread: { + id: NATIVE, + sessionId: NATIVE, + forkedFromId: null, + preview: "", + projectId: null, + ephemeral: false, + modelProvider: "openai", + createdAt: 1782622440, + updatedAt: 1782622440, + status: { type: "idle" }, + path: `/tmp/${NATIVE}.jsonl`, + cwd, + cliVersion: "0.144.0", + source: "vscode", + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }, + model: A.model, + modelProvider: "openai", + serviceTier: null, + cwd, + instructionSources: [], + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: { type: "dangerFullAccess" }, + reasoningEffort: "high", + }; + else if (request.method === "turn/start") { + acceptedStarts.push(request.params ?? {}); + result = { turn: turn(`turn-${acceptedStarts.length}`) }; + } else { + yield* emit({ + id: request.id, + error: { + code: -32601, + message: `Unexpected Codex request: ${request.method}`, + }, + }); + continue; + } + yield* emit({ id: request.id, result }); + } + }), + ), + }), + ); + return makeCodexAdapterV2({ + instanceId: CODEX_DEFAULT_INSTANCE_ID, + settings, + environment: {}, + fileSystem: yield* FileSystem.FileSystem, + idAllocator: yield* IdAllocatorV2, + serverConfig: yield* makeReplayServerConfig("buffer-policy-codex"), + clientFactory: { + open: (input) => + Effect.sync(() => { + opens.push(input.providerSessionId); + return client; + }), + }, + continuationRequests: { + offer: (request) => Queue.offer(offered, request).pipe(Effect.asVoid), + }, + }); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const manager = yield* ProviderSessionManagerV2; + const threadId = ThreadId.make(`thread:codex-buffer:${queued}`); + const projection = orchestrator.getThreadProjection(threadId); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const send = (name: string, modelSelection?: ModelSelection) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(name), + threadId, + messageId: MessageId.make(`message:${name}`), + text: name, + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + ...(modelSelection ? { modelSelection } : {}), + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:codex-buffer"), + title: "Codex buffer", + modelSelection: A, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const started = yield* watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* send("first"); + yield* worker.drain(); + yield* Fiber.join(started); + const settled = yield* watch( + (event) => event.type === "run.updated" && event.payload.status === "waiting", + ); + yield* notify("item/started", { + threadId: NATIVE, + turnId: "turn-1", + item: commandItem("inProgress"), + startedAtMs: 1782622440000, + }); + yield* notify("turn/completed", { threadId: NATIVE, turn: turn("turn-1", "completed") }); + yield* Fiber.join(settled); + yield* worker.drain(); + const binding = (yield* projection).providerThreads[0]!; + const runtime = Option.getOrThrow(yield* manager.get(binding.providerSessionId!)); + assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(binding)); + assert.deepEqual(yield* runtime.executionSelection!(binding), A); + // Unlike Claude, a Codex option change does not replace the process. + const second = yield* watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* send("changed-options", B); + yield* worker.drain(); + yield* Fiber.join(second); + assert.lengthOf(opens, 1); + assert.deepEqual(yield* runtime.executionSelection!(binding), B); + assert.equal(acceptedStarts[0]?.effort, "high"); + assert.equal(acceptedStarts[1]?.effort, "low"); + assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(binding)); + const finishSecond = Effect.gen(function* () { + const ended = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.status === "waiting" && + event.payload.ordinal === 2, + ); + yield* notify("turn/completed", { + threadId: NATIVE, + turn: turn("turn-2", "completed"), + }); + yield* Fiber.join(ended); + yield* worker.drain(); + }); + if (!queued) yield* finishSecond; + yield* notify("item/completed", { + threadId: NATIVE, + turnId: "turn-1", + item: commandItem("completed"), + completedAtMs: 1782622450000, + }); + const request = yield* Queue.take(offered); + assert.equal(request.delivery, "message_text"); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(binding)); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("save-future"), + threadId, + modelSelection: A, + }); + const requests = yield* Queue.unbounded(); + const dispatched = yield* Queue.unbounded(); + yield* Layer.build( + workerLive.pipe( + Layer.provide( + Layer.mergeAll( + idAllocatorLayer, + Layer.succeed(ProviderContinuationRequests, { + offer: (value) => Queue.offer(requests, value).pipe(Effect.asVoid), + take: Queue.take(requests), + }), + Layer.mock(ThreadManagementService)({ + getThreadRecords: orchestrator.getThreadRecords, + dispatch: (value) => + orchestrator + .dispatch(value) + .pipe( + Effect.onExit((exit) => + Queue.offer(dispatched, Exit.isSuccess(exit)).pipe(Effect.asVoid), + ), + ), + }), + ), + ), + ), + ); + yield* Queue.offer(requests, request); + assert.isTrue(yield* Queue.take(dispatched)); + if (queued) { + assert.equal((yield* projection).runs.at(-1)?.status, "queued"); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("save-future-after-queue"), + threadId, + modelSelection: B, + }); + yield* finishSecond; + } + yield* worker.drain(); + assert.lengthOf(acceptedStarts, 3, "completed-command wake starts a new native prompt"); + const current = yield* projection; + assert.deepEqual(current.thread.modelSelection, queued ? B : A); + assert.deepEqual(current.runs.at(-1)?.modelSelection, A); + assert.equal(acceptedStarts[2]?.effort, "high"); + assert.isTrue(encodeJson(acceptedStarts[2]?.input).includes("sleep 60")); + assert.lengthOf(opens, 1); + const completed = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.status === "waiting" && + event.payload.ordinal === 3, + ); + yield* notify("item/completed", { + threadId: NATIVE, + turnId: "turn-1", + item: commandItem("completed"), + completedAtMs: 1782622450000, + }); + yield* notify("turn/completed", { threadId: NATIVE, turn: turn("turn-3", "completed") }); + yield* Fiber.join(completed); + yield* worker.drain(); + assert.equal( + yield* Queue.size(offered), + 0, + "duplicate native completion has no second owner", + ); + assert.lengthOf(acceptedStarts, 3); + assert.deepEqual((yield* projection).thread.modelSelection, queued ? B : A); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: "codex-buffer-policy" }, + makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + }), + ), + ); +} diff --git a/apps/server/src/orchestration-v2/BufferPolicyPooled.integration.test.ts b/apps/server/src/orchestration-v2/BufferPolicyPooled.integration.test.ts new file mode 100644 index 000000000000..2cf94f693aea --- /dev/null +++ b/apps/server/src/orchestration-v2/BufferPolicyPooled.integration.test.ts @@ -0,0 +1,318 @@ +// Pooled runtime ownership fixture; Codex transport is covered separately. +import { assert, it } from "@effect/vitest"; +import { + CommandId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + ThreadId, + type ModelSelection, + type OrchestrationV2ProviderThread, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; +import { ProviderAdapterEnsureThreadError } from "./ProviderAdapter.ts"; +import type { + ProviderAdapterV2Event, + ProviderAdapterV2Shape, + ProviderAdapterV2TurnInput, +} from "./ProviderAdapter.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const driver = ProviderDriverKind.make("codex"); +const instanceId = ProviderInstanceId.make("codex"); +const A: ModelSelection = { + instanceId, + model: "gpt-review", + options: [{ id: "effort", value: "low" }], +}; + +for (const residency of ["idle-release", "resident", "failed-ensure", "server-release"] as const) { + it.effect(`pooled binding after ${residency}`, () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`review-pooled-${residency}`); + const queues = new Map>(); + const opens: string[] = []; + const closes: string[] = []; + const calls: ProviderAdapterV2TurnInput[] = []; + let failEnsure = residency === "failed-ensure"; + const adapter: ProviderAdapterV2Shape = { + driver, + instanceId, + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: (input) => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + queues.set(input.providerSessionId, events); + opens.push(input.providerSessionId); + yield* Effect.addFinalizer(() => + Effect.sync(() => void closes.push(input.providerSessionId)), + ); + const now = yield* DateTime.now; + const bind = (thread: OrchestrationV2ProviderThread) => Effect.succeed(thread); + return { + driver, + instanceId, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: input.modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + ensureThread: ({ threadId, existingProviderThread }) => + Effect.suspend(() => { + if (failEnsure) { + failEnsure = false; + return Effect.fail( + new ProviderAdapterEnsureThreadError({ + driver, + threadId, + cause: "first ensure failed without a native reference", + }), + ); + } + return bind({ + ...existingProviderThread!, + id: + existingProviderThread?.id ?? + ProviderThreadId.make(`provider-thread:${threadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { + driver, + nativeId: `native:${threadId}`, + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }); + }), + resumeThread: ({ providerThread }) => + bind({ ...providerThread, providerSessionId: input.providerSessionId }), + startTurn: (turn) => + Effect.gen(function* () { + calls.push(turn); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: { + driver, + nativeId: `native:${turn.attemptId}`, + strength: "strong", + }, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: yield* DateTime.now, + completedAt: null, + }, + }); + }), + steerTurn: () => Effect.void, + interruptTurn: () => Effect.void, + respondToRuntimeRequest: () => Effect.void, + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }; + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const manager = yield* ProviderSessionManagerV2; + const send = (threadId: ThreadId, name: string) => + Effect.gen(function* () { + const before = calls.length; + const started = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + event.type === "provider-turn.updated" && + event.payload.status === "running", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(name), + threadId, + messageId: MessageId.make(`message:${name}`), + text: name, + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + assert.equal(calls.length, before + 1); + yield* Fiber.join(started); + }); + const finish = (threadId: ThreadId) => + Effect.gen(function* () { + const completed = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + event.type === "run.updated" && + event.payload.status === "waiting", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const projection = yield* orchestrator.getThreadProjection(threadId); + const run = projection.runs.at(-1)!; + const turn = projection.providerTurns.find( + (t) => t.runAttemptId === run.activeAttemptId, + )!; + const binding = projection.providerThreads.find( + (t) => t.id === run.providerThreadId, + )!; + const events = queues.get(binding.providerSessionId!)!; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal: run.ordinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(completed); + yield* worker.drain(); + }); + const t1 = ThreadId.make("thread:pooled-one"); + const t2 = ThreadId.make("thread:pooled-two"); + for (const id of [t1, t2]) { + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create:${id}`), + threadId: id, + projectId: ProjectId.make("project:pooled"), + title: "Pooled", + modelSelection: A, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + } + if (residency === "failed-ensure") { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("failed-ensure"), + threadId: t1, + messageId: MessageId.make("failed-ensure"), + text: "Failed prompt must not replay", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + const failed = yield* orchestrator.getThreadProjection(t1); + assert.equal(failed.runs[0]?.status, "starting"); + assert.isNull(failed.providerThreads[0]?.nativeThreadRef); + assert.lengthOf(calls, 0); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("cancel-failed-ensure"), + threadId: t1, + runId: failed.runs[0]!.id, + }); + yield* worker.drain(); + yield* send(t1, "explicit-ensure-retry"); + const recovered = yield* orchestrator.getThreadProjection(t1); + assert.lengthOf(calls, 1); + assert.lengthOf(recovered.runs, 2); + assert.equal( + calls[0]?.providerThread.id, + failed.providerThreads[0]?.id, + "retry retains the app binding rather than creating a selection handoff", + ); + assert.isTrue(calls[0]!.message.text.endsWith("User message:\nexplicit-ensure-retry")); + assert.isTrue( + recovered.contextHandoffs.every( + (handoff) => handoff.strategy === "delta_since_target_last_seen", + ), + "ordinary history catch-up remains available", + ); + assert.equal(recovered.runs[0]?.status, "interrupted"); + return; + } + yield* send(t1, "one-first"); + yield* finish(t1); + const shared = calls[0]!.providerThread.providerSessionId!; + if (residency === "idle-release" || residency === "server-release") { + yield* manager.release({ + providerSessionId: shared, + reason: residency === "idle-release" ? "idle_timeout" : "server_shutdown", + }); + yield* worker.drain(); + } + yield* send(t2, "two-first"); + yield* send(t1, "one-second"); + assert.equal(opens.length, residency === "resident" ? 1 : 2); + assert.equal(closes.length, residency === "resident" ? 0 : 1); + // Pooled contract: both threads share one resident provider process. + assert.equal( + calls.at(-1)!.providerThread.providerSessionId, + calls.at(-2)!.providerThread.providerSessionId, + ); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: `review-pooled-${residency}` }, + makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + }), + ), + ); +} diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 4841a5762480..0e55a923f301 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -34,6 +34,7 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ detail: Schema.optional(Schema.String), /** Set on terminal detaches (thread archive/delete): revoke the thread's MCP credentials. */ revokeMcpCredential: Schema.optional(Schema.Boolean), + preserveBufferedOutput: Schema.optional(Schema.Boolean), }), Schema.Struct({ type: Schema.Literal("provider-turn.start"), diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index c6a6a67d0ee0..57b9c2b144a7 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -129,6 +129,9 @@ export const executorLayer: Layer.Layer< .detach({ providerSessionId: effect.request.providerSessionId, threadId: effect.threadId, + ...(effect.request.preserveBufferedOutput === undefined + ? {} + : { preserveBufferedOutput: effect.request.preserveBufferedOutput }), ...(effect.request.detail === undefined ? {} : { detail: effect.request.detail }), ...(effect.request.revokeMcpCredential === undefined ? {} diff --git a/apps/server/src/orchestration-v2/ExecutionAdmission.integration.test.ts b/apps/server/src/orchestration-v2/ExecutionAdmission.integration.test.ts new file mode 100644 index 000000000000..af73cd5a019e --- /dev/null +++ b/apps/server/src/orchestration-v2/ExecutionAdmission.integration.test.ts @@ -0,0 +1,356 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + ThreadId, + type ModelSelection, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { + type ProviderAdapterV2Event, + type ProviderAdapterV2Shape, + type ProviderAdapterV2TurnInput, +} from "./ProviderAdapter.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const driver = ProviderDriverKind.make("codex"); +const instanceId = ProviderInstanceId.make("codex"); +const selection = (effort: string): ModelSelection => ({ + instanceId, + model: "same-model", + options: [ + { id: "effort", value: effort }, + { id: "enabled", value: true }, + ], +}); +const A = selection("low"); +const B = selection("medium"); +const C = selection("high"); +const effort = (value: ModelSelection) => + value.options?.find((option) => option.id === "effort")?.value; + +for (const origin of ["web", "server", "mcp"] as const) { + for (const pooled of [false, true]) { + for (const transition of [ + "apply_on_next_turn", + "restart_session", + "handoff", + "reject", + ] as const) { + for (const path of ["ordinary", "queued", "selection-set"] as const) { + if (transition === "reject" && path === "queued") continue; + const name = `${origin}-${path}-${transition}-${pooled ? "pooled" : "exclusive"}`; + it.effect(`admits from full execution evidence: ${name}`, () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`execution-admission-${name}`); + const capabilities = { + ...CodexProviderCapabilitiesV2, + sessions: { + ...CodexProviderCapabilitiesV2.sessions, + supportsMultipleProviderThreadsPerSession: pooled, + }, + }; + const calls: ProviderAdapterV2TurnInput[] = []; + const opens: string[] = []; + const closes: string[] = []; + const queues = new Map>(); + const planned: ModelSelection[] = []; + const adapter: ProviderAdapterV2Shape = { + driver, + instanceId, + getCapabilities: () => Effect.succeed(capabilities), + planSelectionTransition: ({ current, target }) => + Effect.sync(() => { + planned.push(current); + if (effort(current) !== "low" || effort(target) !== "high") + return { type: "apply_on_next_turn" as const }; + if (transition === "reject") + return { type: "reject" as const, reason: "Unsupported target." }; + return { + type: + transition === "handoff" ? ("create_with_handoff" as const) : transition, + }; + }), + openSession: (input) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Queue.unbounded(); + let loaded = input.modelSelection; + opens.push(input.providerSessionId); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + closes.push(input.providerSessionId); + }), + ); + return { + driver, + instanceId, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: input.modelSelection.model, + capabilities, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + ensureThread: ({ threadId, existingProviderThread }) => + Effect.succeed({ + id: + existingProviderThread?.id ?? + ProviderThreadId.make(`binding:${input.providerSessionId}:${threadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { + driver, + nativeId: `native:${input.providerSessionId}`, + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => + Effect.gen(function* () { + assert.isFalse( + effort(loaded) === "low" && + effort(turn.modelSelection) === "high" && + transition !== "apply_on_next_turn", + "an incompatible native operation must not reach the adapter", + ); + calls.push(turn); + loaded = turn.modelSelection; + queues.set(turn.attemptId, events); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: ProviderTurnId.make(`turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: { + driver, + nativeId: `native:${turn.attemptId}`, + strength: "strong", + }, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: yield* DateTime.now, + completedAt: null, + }, + }); + }), + steerTurn: () => Effect.void, + interruptTurn: () => Effect.void, + respondToRuntimeRequest: () => Effect.void, + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }; + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const threadId = ThreadId.make(`thread:${name}`); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const dispatch = (command: { + name: string; + modelSelection?: ModelSelection; + queued?: boolean; + }) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(command.name), + threadId, + messageId: MessageId.make(`message:${command.name}`), + text: command.name, + attachments: [], + ...(command.modelSelection === undefined + ? {} + : { modelSelection: command.modelSelection }), + dispatchMode: { + type: command.queued ? "queue_after_active" : "start_immediately", + }, + createdBy: origin === "web" ? "user" : "agent", + creationSource: origin, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:admission"), + title: "Admission", + modelSelection: A, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const firstRunning = yield* watch( + (event) => + event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* dispatch({ name: "first" }); + yield* worker.drain(); + yield* Fiber.join(firstRunning); + const first = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("desired"), + threadId, + messageId: MessageId.make("message:desired"), + text: "B next turn", + attachments: [], + modelSelection: B, + dispatchMode: { type: "steer_active", targetRunId: first.id }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + if (path === "queued") + yield* dispatch({ name: "following", modelSelection: C, queued: true }); + const waiting = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === first.id && + event.payload.status === "waiting", + ); + const turn = (yield* orchestrator.getThreadProjection(threadId)).providerTurns[0]!; + yield* Queue.offer(queues.get(first.activeAttemptId!)!, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(queues.get(first.activeAttemptId!)!, { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal: first.ordinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(waiting); + if (path !== "queued") yield* worker.drain(); + const before = yield* orchestrator.getThreadProjection(threadId); + if (path === "selection-set") { + const result = yield* Effect.exit( + orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("set"), + threadId, + modelSelection: C, + }), + ); + assert.equal(result._tag, transition === "reject" ? "Failure" : "Success"); + if (transition !== "reject") { + yield* worker.drain(); + yield* dispatch({ name: "following" }); + } + } else if (path === "ordinary") { + const result = yield* Effect.exit( + dispatch({ name: "following", modelSelection: C }), + ); + assert.equal(result._tag, transition === "reject" ? "Failure" : "Success"); + } + yield* worker.drain(); + if (transition === "reject") { + const after = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(after.thread.modelSelection, B); + assert.deepEqual(after.runs, before.runs); + assert.deepEqual(after.messages, before.messages); + assert.equal(calls.length, 1); + assert.equal(opens.length, 1); + return; + } + const after = yield* orchestrator.getThreadProjection(threadId); + assert.equal(calls.length, 2); + assert.deepEqual(calls[1]!.modelSelection, C); + assert.deepEqual(after.thread.modelSelection, C); + assert.isTrue( + planned.some((current) => effort(current) === "low"), + "desired B never erases loaded A evidence", + ); + assert.equal(opens.length, transition === "apply_on_next_turn" ? 1 : 2); + if (transition === "handoff") { + assert.notEqual(calls[1]!.providerThread.id, calls[0]!.providerThread.id); + assert.notEqual( + calls[1]!.providerThread.nativeThreadRef?.nativeId, + calls[0]!.providerThread.nativeThreadRef?.nativeId, + ); + assert.isAbove(after.contextTransfers.length, 0); + } + if (transition === "restart_session" && path !== "selection-set") { + assert.equal( + calls[1]!.providerThread.nativeThreadRef?.nativeId, + calls[0]!.providerThread.nativeThreadRef?.nativeId, + ); + } + if (!pooled && transition !== "apply_on_next_turn") + assert.include(closes, opens[0]!); + yield* orchestrator.dispatch({ + type: "provider-session.detach", + commandId: CommandId.make("stop"), + threadId, + providerSessionId: calls[1]!.providerThread.providerSessionId!, + reason: "Explicit Stop", + }); + yield* worker.drain(); + if (!pooled) assert.include(closes, opens.at(-1)!); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: `execution-admission-${name}` }, + makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + }), + ), + ); + } + } + } +} diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 664243bd3ae0..7d28d5caef84 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -89,12 +89,16 @@ import { type ProjectionRecords, type ProjectionCheckpointContext, } from "./ProjectionStore.ts"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import { + PROVIDER_BUFFERED_OUTPUT_MESSAGE, + PROVIDER_RUNNING_WORK_MESSAGE, + type ProviderAdapterV2Shape, +} from "./ProviderAdapter.ts"; import { ProviderAdapterRegistryV2 } from "./ProviderAdapterRegistry.ts"; import { ProviderContinuationRequests } from "./ProviderContinuationRequests.ts"; import { makeProviderFailure } from "./ProviderFailure.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; -import { ProviderSwitchServiceV2 } from "./ProviderSwitchService.ts"; +import { ProviderSwitchPlanError, ProviderSwitchServiceV2 } from "./ProviderSwitchService.ts"; import { isAutomaticCompletionRun, queuedRunsInDeliveryOrder } from "./QueuedRunOrder.ts"; import { RuntimePolicyV2 } from "./RuntimePolicy.ts"; import { @@ -701,6 +705,122 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); + const planExecutionSelection = Effect.fnUntraced(function* (input: { + readonly projection: Pick< + OrchestrationV2ThreadProjection, + "thread" | "providerThreads" | "providerSessions" + >; + readonly targetModelSelection: ModelSelection; + readonly buffered?: boolean; + readonly admit?: boolean; + }) { + const { projection, targetModelSelection } = input; + const binding = projection.providerThreads.find( + (thread) => thread.id === projection.thread.activeProviderThreadId, + ); + const sessionId = binding?.providerSessionId; + if (binding === undefined || sessionId === null || sessionId === undefined) { + return yield* providerSwitchService.plan({ + ...input, + executionSelection: null, + freshAttachment: true, + }); + } + const resident = yield* providerSessions.get(sessionId); + const current = + Option.isSome(resident) && resident.value.executionSelection !== undefined + ? yield* resident.value.executionSelection(binding) + : null; + const pending = Option.isSome(resident) + ? yield* ( + resident.value.hasPendingBackgroundWorkForThread?.(binding) ?? + resident.value.hasPendingBackgroundWork ?? + Effect.succeed(false) + ) + : false; + if (input.buffered === true) { + const ownsOutput = Option.isSome(resident) + ? yield* resident.value.hasBufferedOutputForThread?.(binding) ?? Effect.succeed(false) + : false; + const producingSelection = + Option.isSome(resident) && resident.value.bufferedExecutionSelection !== undefined + ? yield* resident.value.bufferedExecutionSelection(binding) + : current; + if ( + !ownsOutput || + producingSelection === null || + !modelSelectionsEqual(producingSelection, targetModelSelection) + ) { + return yield* new ProviderSwitchPlanError({ + threadId: projection.thread.id, + targetProviderInstanceId: targetModelSelection.instanceId, + cause: "Buffered output cannot be safely admitted on this binding.", + }); + } + return { + instanceChanged: false, + modelChanged: false, + targetProviderThreadId: binding.id, + releaseProviderSessionIds: [], + transition: { type: "reuse" as const }, + }; + } + const attached = Option.isSome(resident) + ? yield* resident.value.hasResidentBinding?.(binding) ?? Effect.succeed(current !== null) + : false; + const projectedLive = projection.providerSessions.some( + (session) => + session.id === sessionId && session.status !== "stopped" && session.status !== "error", + ); + const plan = yield* providerSwitchService + .plan({ + ...input, + executionSelection: attached && !projectedLive ? null : current, + freshAttachment: + !attached || + (Option.isSome(resident) && + (yield* ( + resident.value.reappliesFullSelection?.(targetModelSelection) ?? Effect.succeed(false) + ))), + }) + .pipe( + Effect.map((plan) => + attached && plan.transition.type === "restart_and_resume" + ? { ...plan, releaseProviderSessionIds: [sessionId] } + : plan, + ), + ); + const replacing = + plan.transition.type === "restart_and_resume" || + plan.transition.type === "create_with_handoff"; + // Native output and work are the resident runtime's, whether or not the + // manager has seen this binding load or run: a binding opened by a + // rollback can own a wake before any turn of T3's. + if (input.admit === true && Option.isSome(resident)) { + const buffered = yield* ( + resident.value.hasBufferedOutputForThread?.(binding) ?? Effect.succeed(false) + ); + if ( + buffered && + (current === null || replacing || !modelSelectionsEqual(current, targetModelSelection)) + ) { + return yield* new ProviderSwitchPlanError({ + threadId: projection.thread.id, + targetProviderInstanceId: targetModelSelection.instanceId, + cause: PROVIDER_BUFFERED_OUTPUT_MESSAGE, + }); + } + if (pending && replacing) { + return yield* new ProviderSwitchPlanError({ + threadId: projection.thread.id, + targetProviderInstanceId: targetModelSelection.instanceId, + cause: PROVIDER_RUNNING_WORK_MESSAGE, + }); + } + } + return plan; + }); + const providerSessionIdFor = (input: { readonly adapter: ProviderAdapterV2Shape; readonly providerInstanceId: ProviderInstanceId; @@ -1192,7 +1312,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio candidate.runId === queuedRun.id && candidate.messageId === queuedRun.userMessageId, ); - const queuedProviderThread = projection.providerThreads.find( + let queuedProviderThread = projection.providerThreads.find( (candidate) => candidate.id === providerThreadId, ); const storedCheckpointScope = projection.checkpointScopes.find( @@ -1218,20 +1338,39 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projection.thread.modelSelection, queuedRun.modelSelection, ); - const switchPlan = selectionChanged - ? yield* providerSwitchService - .plan({ projection, targetModelSelection: queuedRun.modelSelection }) - .pipe( - Effect.mapError( - (cause) => - new OrchestratorDispatchError({ - commandId, - commandType: "message.dispatch", - cause, - }), - ), - ) - : null; + const switchPlan = yield* planExecutionSelection({ + projection, + targetModelSelection: queuedRun.modelSelection, + admit: true, + buffered: + queuedMessage.createdBy === "agent" && queuedMessage.creationSource === "provider", + }).pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId, + commandType: "message.dispatch", + cause, + }), + ), + ); + const queuedHandoff = + switchPlan.transition.type === "create_with_handoff" && + !switchPlan.instanceChanged && + queuedProviderThread.nativeThreadRef !== null; + if (queuedHandoff) { + queuedProviderThread = { + ...queuedProviderThread, + id: idAllocator.derive.providerThread({ + driver: queuedProviderThread.driver, + nativeThreadId: `handoff:${queuedRun.id}`, + }), + providerSessionId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + nativeMetadata: undefined, + }; + } const activeProviderThread = projection.providerThreads.find( (candidate) => candidate.id === projection.thread.activeProviderThreadId, ); @@ -1278,7 +1417,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const coveredRuns = canResumeAcrossInstances || latestHandoffRun === undefined || - latestHandoffRun.providerInstanceId === queuedRun.providerInstanceId + (latestHandoffRun.providerInstanceId === queuedRun.providerInstanceId && !queuedHandoff) ? [] : projection.runs.filter( (run) => @@ -1417,16 +1556,33 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const providerSessionId = (!canResumeAcrossInstances && queuedProviderThread.providerSessionId !== null && + Option.isSome( + yield* providerSessions.get(queuedProviderThread.providerSessionId).pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId, + commandType: "message.dispatch", + cause, + }), + ), + ), + ) && !switchPlan?.releaseProviderSessionIds.includes(queuedProviderThread.providerSessionId) ? queuedProviderThread.providerSessionId : null) ?? (yield* providerAdapters.get(queuedRun.providerInstanceId).pipe( Effect.flatMap((adapter) => - providerSessionIdFor({ - adapter, - providerInstanceId: queuedRun.providerInstanceId, - threadId, - }), + switchPlan.transition.type === "restart_and_resume" || queuedHandoff + ? idAllocator.allocate.providerSession({ + providerInstanceId: queuedRun.providerInstanceId, + threadId, + }) + : providerSessionIdFor({ + adapter, + providerInstanceId: queuedRun.providerInstanceId, + threadId, + }), ), Effect.mapError( (cause) => @@ -1451,6 +1607,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }; const startingRun: OrchestrationV2Run = { ...queuedRun, + providerThreadId: queuedProviderThread.id, status: "starting", queuePosition: null, startedAt: null, @@ -1554,13 +1711,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : []; const sessionsToDetach = projection.providerSessions.filter( (session) => - switchPlan?.releaseProviderSessionIds.includes(session.id) && + (switchPlan.releaseProviderSessionIds.includes(session.id) || + (queuedHandoff && session.id === activeProviderThread?.providerSessionId)) && session.status !== "stopped" && session.status !== "error", ); yield* writeSystemEvents( [ - ...(selectionChanged + ...(selectionChanged && + queuedMessage.notification === undefined && + queuedMessage.delegatedCompletion === undefined ? [ { type: @@ -1662,6 +1822,32 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, })), ...checkpointEvents, + ...(queuedHandoff + ? [ + { + type: "node.updated" as const, + threadId, + runId: queuedRun.id, + nodeId: rootNode.id, + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { + ...rootNode, + providerThreadId: queuedProviderThread.id, + checkpointScopeId: checkpointScope.id, + }, + }, + { + type: "run-attempt.updated" as const, + threadId, + runId: queuedRun.id, + nodeId: rootNode.id, + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { ...attempt, providerThreadId: queuedProviderThread.id }, + }, + ] + : []), { type: "provider-thread.updated", threadId, @@ -1696,6 +1882,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio request: { type: "provider-session.detach" as const, providerSessionId: session.id, + preserveBufferedOutput: true, detail: "Provider or model selection changed.", }, })), @@ -2408,12 +2595,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - return yield* providerSwitchService - .plan({ - projection: providerContext!, - targetModelSelection: command.modelSelection, - }) - .pipe(mapDispatchError(command)); + return yield* planExecutionSelection({ + projection: providerContext!, + targetModelSelection: command.modelSelection, + }).pipe(mapDispatchError(command)); }) : null; @@ -3056,6 +3241,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : command.type === "thread.runtime-mode.set" ? "Runtime mode changed." : "Provider or model selection changed.", + ...(command.type === "thread.model-selection.set" || + command.type === "provider.switch" + ? { preserveBufferedOutput: true } + : {}), // Terminal detaches revoke the thread's MCP credentials; other // detach reasons keep them so a re-attaching provider process // stays authorized. @@ -4252,7 +4441,38 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); projection = yield* getProjectionWithPendingEvents(command.threadId, events); } - const modelSelection = command.modelSelection ?? projection.thread.modelSelection; + let modelSelection = command.modelSelection ?? projection.thread.modelSelection; + if ( + command.createdBy === "agent" && + command.creationSource === "provider" && + command.notification !== undefined + ) { + const owner = projection.providerThreads.find( + (thread) => thread.id === projection.thread.activeProviderThreadId, + ); + const resident = + owner?.providerSessionId == null + ? Option.none() + : yield* providerSessions.get(owner.providerSessionId).pipe(mapDispatchError(command)); + const captured = + owner !== undefined && + Option.isSome(resident) && + (resident.value.bufferedExecutionSelection !== undefined || + resident.value.executionSelection !== undefined) + ? yield* ( + resident.value.bufferedExecutionSelection?.(owner) ?? + resident.value.executionSelection!(owner) + ) + : null; + if (captured === null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Buffered provider output has no trustworthy execution selection.", + }); + } + modelSelection = captured; + } let dispatchMode = resolveMessageDispatchIntent( projection, command.dispatchMode, @@ -4752,8 +4972,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); const now = yield* DateTime.now; if ( - !modelSelectionsEqual(projection.thread.modelSelection, modelSelection) || - projection.thread.providerInstanceId !== modelSelection.instanceId + command.notification === undefined && + command.delegatedCompletion === undefined && + (!modelSelectionsEqual(projection.thread.modelSelection, modelSelection) || + projection.thread.providerInstanceId !== modelSelection.instanceId) ) { yield* emit( events, @@ -4774,6 +4996,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, }); } + const admission = yield* planExecutionSelection({ + projection, + targetModelSelection: modelSelection, + admit: true, + buffered: command.createdBy === "agent" && command.creationSource === "provider", + }).pipe(mapDispatchError(command)); const ordinal = nextRunOrdinal(projection); const runId = idAllocator.derive.run({ threadId: command.threadId, ordinal }); const latestCompletedRun = projection.runs.findLast((run) => run.status === "completed"); @@ -4782,13 +5010,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projection.thread.historyOrigin === "v1_import" ? yield* readHandoffItems(command.threadId, [null]) : []; + const selectionHandoff = + activeProviderThread !== undefined && + activeProviderThread.providerInstanceId === modelSelection.instanceId && + admission.transition.type === "create_with_handoff"; const isProviderSwitch = activeProviderThread !== undefined && - activeProviderThread.providerInstanceId !== modelSelection.instanceId; + (activeProviderThread.providerInstanceId !== modelSelection.instanceId || selectionHandoff); // Account overlays share native history. Selection commands may already // have updated the app thread, so classify against the native thread's owner. const canResumeAcrossInstances = isProviderSwitch && + !selectionHandoff && activeProviderThread.nativeThreadRef !== null && (yield* providerSwitchService .plan({ @@ -4809,7 +5042,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( pendingForkTransfer === undefined && pendingMergeBackTransfer === undefined && - !isProviderSwitch + !isProviderSwitch && + (activeProviderThread === undefined || admission.transition.type !== "create_with_handoff") ) { const adapter = yield* providerAdapters.get(modelSelection.instanceId).pipe( Effect.mapError( @@ -4821,15 +5055,60 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - const providerSessionId = - activeProviderThread?.providerSessionId ?? - (yield* mapDispatchError(command)( - providerSessionIdFor({ - adapter, - providerInstanceId: modelSelection.instanceId, + const replacesBinding = admission.transition.type === "restart_and_resume"; + const providerSessionId = replacesBinding + ? yield* idAllocator.allocate + .providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: command.threadId, + }) + .pipe(mapDispatchError(command)) + : activeProviderThread?.providerSessionId != null && + Option.isSome( + yield* providerSessions + .get(activeProviderThread.providerSessionId) + .pipe(mapDispatchError(command)), + ) + ? activeProviderThread.providerSessionId + : yield* mapDispatchError(command)( + providerSessionIdFor({ + adapter, + providerInstanceId: modelSelection.instanceId, + threadId: command.threadId, + }), + ); + if (replacesBinding && activeProviderThread?.providerSessionId != null) { + const oldSessionId = activeProviderThread.providerSessionId; + yield* emit( + events, + command, + )({ + type: "provider-session.detached", + threadId: command.threadId, + driver: activeProviderThread.driver, + providerInstanceId: activeProviderThread.providerInstanceId, + occurredAt: now, + payload: { + providerSessionId: oldSessionId, + detachedAt: now, + reason: "Execution selection requires a fresh binding.", + }, + }); + yield* Ref.update(effects, (existing) => [ + ...existing, + { + id: `effect:${command.commandId}:provider-session.detach:${oldSessionId}`, + commandId: command.commandId, threadId: command.threadId, - }), - )); + request: { + type: "provider-session.detach", + providerSessionId: oldSessionId, + preserveBufferedOutput: true, + detail: "Execution selection requires a fresh binding.", + }, + } satisfies PendingOrchestrationEffectV2, + ]); + } const providerThreadId = activeProviderThread?.id ?? idAllocator.derive.providerThread({ @@ -5201,11 +5480,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - const targetProviderThread = - isProviderSwitch && !canResumeAcrossInstances + const targetProviderThread = selectionHandoff + ? undefined + : isProviderSwitch && !canResumeAcrossInstances ? rootProviderThreadsForProvider(projection, modelSelection.instanceId)[0] : activeProviderThread; const providerSessionId = + (selectionHandoff || canResumeAcrossInstances + ? yield* idAllocator.allocate + .providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: command.threadId, + }) + .pipe(mapDispatchError(command)) + : undefined) ?? (canResumeAcrossInstances ? undefined : targetProviderThread?.providerSessionId) ?? (yield* mapDispatchError(command)( providerSessionIdFor({ @@ -6037,7 +6325,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } - if (canResumeAcrossInstances && activeProviderThread.providerSessionId !== null) { + if ( + (canResumeAcrossInstances || selectionHandoff) && + activeProviderThread.providerSessionId !== null + ) { const previousProviderSessionId = activeProviderThread.providerSessionId; yield* emitEvent({ type: "provider-session.detached", @@ -6060,7 +6351,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio request: { type: "provider-session.detach", providerSessionId: previousProviderSessionId, - detail: "Provider account changed; continuing the native thread.", + detail: "Provider selection changed.", + preserveBufferedOutput: true, }, } satisfies PendingOrchestrationEffectV2, ]); @@ -7562,11 +7854,39 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio activeProviderThreadId: projection.thread.activeProviderThreadId, runs: projection.runs, }).length > 0; - const providerTurn = projection.providerTurns.findLast( - (candidate) => - candidate.runAttemptId === run?.activeAttemptId && - (candidate.status === "running" || hasBackgroundWork), - ); + const retainedRuntime = + run?.id === projection.runs.at(-1)?.id && providerThread?.providerSessionId != null + ? yield* providerSessions + .get(providerThread.providerSessionId) + .pipe(mapDispatchError(command)) + : Option.none(); + const hasRetainedOutput = + providerThread !== undefined && + Option.isSome(retainedRuntime) && + (yield* ( + retainedRuntime.value.hasBufferedOutputForThread?.(providerThread) ?? + Effect.succeed(false) + )); + const hasNativeSubagents = + run?.id === projection.runs.at(-1)?.id && + projection.subagents.some( + (task) => + task.origin === "provider_native" && + task.status === "running" && + task.providerInstanceId === providerThread?.providerInstanceId, + ); + const hasPendingProviderWork = hasBackgroundWork || hasRetainedOutput || hasNativeSubagents; + const providerTurn = + projection.providerTurns.findLast( + (candidate) => + candidate.runAttemptId === run?.activeAttemptId && + (candidate.status === "running" || hasPendingProviderWork), + ) ?? + (hasPendingProviderWork + ? projection.providerTurns.findLast( + (candidate) => candidate.providerThreadId === providerThread?.id, + ) + : undefined); if (run === undefined || rootNode === undefined || providerThread === undefined) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index 33afe1f9a07f..606bfbf7b14d 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -354,7 +354,32 @@ export class ProviderAdapterProtocolError extends Schema.TaggedError()( + "ProviderAdapterBufferedOutputError", + { driver: ProviderDriverKind, providerThreadId: ProviderThreadId }, +) { + override get message(): string { + return PROVIDER_BUFFERED_OUTPUT_MESSAGE; + } +} + +export class ProviderAdapterRunningWorkError extends Schema.TaggedError()( + "ProviderAdapterRunningWorkError", + { driver: ProviderDriverKind }, +) { + override get message(): string { + return PROVIDER_RUNNING_WORK_MESSAGE; + } +} + export const ProviderAdapterV2Error = Schema.Union([ + ProviderAdapterBufferedOutputError, + ProviderAdapterRunningWorkError, ProviderAdapterCapabilitiesError, ProviderAdapterOpenSessionError, ProviderAdapterCloseSessionError, @@ -394,6 +419,8 @@ export interface ProviderAdapterV2EnsureThreadInput { } export interface ProviderAdapterV2TurnInput { + /** The manager has no acknowledged selection for this resident binding. */ + readonly reapplySelection?: boolean; readonly appThread: OrchestrationV2AppThread; readonly threadId: ThreadId; readonly runId: RunId; @@ -493,6 +520,36 @@ export interface ProviderAdapterV2SessionRuntime { * Adapter runtimes may omit this and expose only their single-consumer event stream. */ readonly subscribeEvents?: Effect.Effect; + /** Resident native binding, including a detached binding awaiting replacement. */ + readonly hasResidentBinding?: ( + providerThread: OrchestrationV2ProviderThread, + ) => Effect.Effect; + /** Output retained by this native binding, not its running-work roster. */ + readonly hasBufferedOutputForThread?: ( + providerThread: OrchestrationV2ProviderThread, + ) => Effect.Effect; + /** Complete producing-turn selection retained with native output, even after query exit. */ + readonly bufferedExecutionSelection?: ( + providerThread: OrchestrationV2ProviderThread, + ) => Effect.Effect; + /** Last acknowledged complete selection on the same still-live native query. */ + readonly liveExecutionSelection?: ( + providerThread: OrchestrationV2ProviderThread, + ) => Effect.Effect; + /** A failed same-selection start cannot change native selection configuration. */ + readonly preservesSelectionOnSameSelectionFailure?: boolean; + /** True only when every supported selection field is explicit in the next wire request. */ + readonly reappliesFullSelection?: (selection: ModelSelection) => Effect.Effect; + /** Continuations only deliver existing output and cannot acknowledge configuration. */ + readonly continuationDrainsOutput?: boolean; + /** Settled configuration check after a real start succeeds. Deterministic + * degradation can acknowledge the request; buffered output keeps its applied label. + */ + readonly selectionAcknowledged?: (selection: ModelSelection) => Effect.Effect; + /** Residency-owned evidence; absent or null means selection is not established. */ + readonly executionSelection?: ( + providerThread: OrchestrationV2ProviderThread, + ) => Effect.Effect; /** * Adapters whose native runtime can hold pending work outside an active * turn (for example Claude background tasks and their wake turns) report it @@ -529,6 +586,14 @@ export interface ProviderAdapterV2SessionRuntime { readonly providerThread: OrchestrationV2ProviderThread; }, ) => Effect.Effect; + /** + * Success acknowledges handling the full requested model/options for this operation, + * after native configuration or prompt acceptance, including lazy setup. + * Deterministically skipped/rejected options settle a request without claiming + * their values were applied. Indeterminate steps cannot acknowledge it. + * Merely caching the selection or scheduling setup does not acknowledge it. + * Failure/interruption may have partially applied it and establish no certainty. + */ readonly startTurn: ( input: ProviderAdapterV2TurnInput, ) => Effect.Effect; diff --git a/apps/server/src/orchestration-v2/ProviderFailure.ts b/apps/server/src/orchestration-v2/ProviderFailure.ts index b0c7e5fdd627..277e6380e925 100644 --- a/apps/server/src/orchestration-v2/ProviderFailure.ts +++ b/apps/server/src/orchestration-v2/ProviderFailure.ts @@ -14,6 +14,10 @@ import * as DateTime from "effect/DateTime"; import * as Cause from "effect/Cause"; import type { IdAllocatorV2Shape } from "./IdAllocator.ts"; +import { + PROVIDER_BUFFERED_OUTPUT_MESSAGE, + PROVIDER_RUNNING_WORK_MESSAGE, +} from "./ProviderAdapter.ts"; import { ContextHandoffBudgetError } from "./ContextHandoffDelivery.ts"; export const MAX_PROVIDER_FAILURE_MESSAGE_LENGTH = 4_096; @@ -34,6 +38,10 @@ function causeMessage(cause: unknown): string | undefined { } if (typeof cause !== "object") break; switch ((cause as Record)._tag) { + case "ProviderAdapterRunningWorkError": + return PROVIDER_RUNNING_WORK_MESSAGE; + case "ProviderAdapterBufferedOutputError": + return PROVIDER_BUFFERED_OUTPUT_MESSAGE; case "ContextHandoffBudgetError": return new ContextHandoffBudgetError().message; case "ContextHandoffDeliveryUncertainError": diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 63d4c57fb663..8fcf5d88c2e1 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -14,6 +14,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ProviderSessionId, + ProviderTurnId, ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -44,6 +45,9 @@ import * as IdAllocator from "./IdAllocator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import { ProviderAdapterEventStreamError, + ProviderAdapterBufferedOutputError, + ProviderAdapterRunningWorkError, + type ProviderAdapterV2TurnInput, type ProviderAdapterV2Event, ProviderAdapterProtocolError, type ProviderAdapterV2RuntimePolicy, @@ -244,8 +248,12 @@ function makeProviderAdapter( readonly initialProviderItemIdentityVersion?: 2; }) => Effect.Effect; readonly hasPendingBackgroundWork?: Effect.Effect; + readonly hasBufferedOutputForThread?: ( + thread: OrchestrationV2ProviderThread, + ) => Effect.Effect; readonly hangSessionScopeClose?: boolean; readonly beforeUnload?: Effect.Effect; + readonly startTurn?: ProviderAdapterV2SessionRuntime["startTurn"]; } = {}, ): ProviderAdapterV2Shape { return { @@ -310,13 +318,16 @@ function makeProviderAdapter( ...(options.hasPendingBackgroundWork === undefined ? {} : { hasPendingBackgroundWork: options.hasPendingBackgroundWork }), + ...(options.hasBufferedOutputForThread === undefined + ? {} + : { hasBufferedOutputForThread: options.hasBufferedOutputForThread }), ensureThread: () => unimplemented("ensureThread unused in test"), resumeThread: (threadInput) => Ref.update(state, (current) => ({ ...current, resumeCount: current.resumeCount + 1, })).pipe(Effect.as(threadInput.providerThread)), - startTurn: () => Effect.void, + startTurn: options.startTurn ?? (() => Effect.void), steerTurn: () => Effect.void, interruptTurn: () => Ref.update(state, (current) => ({ @@ -359,8 +370,12 @@ function makeTestLayer(input: { }) => Effect.Effect; readonly failReleaseEventWrites?: boolean; readonly hasPendingBackgroundWork?: Effect.Effect; + readonly hasBufferedOutputForThread?: ( + thread: OrchestrationV2ProviderThread, + ) => Effect.Effect; readonly hangSessionScopeClose?: boolean; readonly beforeUnload?: Effect.Effect; + readonly startTurn?: ProviderAdapterV2SessionRuntime["startTurn"]; readonly serverSettingsLayer?: ReturnType; readonly projectServiceLayer?: Layer.Layer; }) { @@ -370,12 +385,16 @@ function makeTestLayer(input: { const registryLayer = ProviderAdapterRegistry.makeSingleLayer( makeProviderAdapter(input.state, { failEventStream: input.failEventStream ?? false, + ...(input.startTurn === undefined ? {} : { startTurn: input.startTurn }), ...(input.capabilities === undefined ? {} : { capabilities: input.capabilities }), ...(input.mcpConfigs === undefined ? {} : { mcpConfigs: input.mcpConfigs }), ...(input.beforeOpen === undefined ? {} : { beforeOpen: input.beforeOpen }), ...(input.hasPendingBackgroundWork === undefined ? {} : { hasPendingBackgroundWork: input.hasPendingBackgroundWork }), + ...(input.hasBufferedOutputForThread === undefined + ? {} + : { hasBufferedOutputForThread: input.hasBufferedOutputForThread }), ...(input.hangSessionScopeClose === undefined ? {} : { hangSessionScopeClose: input.hangSessionScopeClose }), @@ -410,6 +429,85 @@ function makeTestLayer(input: { ).pipe(Layer.provide(NodeServices.layer)); } +for (const Refusal of [ProviderAdapterBufferedOutputError, ProviderAdapterRunningWorkError]) { + it.effect( + `restores prior acknowledgment after ${Refusal.name} without adapter live evidence`, + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + let refuse = false; + yield* Effect.gen(function* () { + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const eventSink = yield* EventSink.EventSinkV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:typed-refusal"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const runtime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const binding = makeProviderThread({ idAllocator, threadId, providerSessionId, now }); + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + const turn: ProviderAdapterV2TurnInput = { + appThread: (yield* projectionStore.getThreadProjection(threadId)).thread, + threadId, + runId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId }), + providerThread: binding, + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId, ordinal: 1 }), + text: "first", + attachments: [], + }, + modelSelection, + runtimePolicy, + }; + yield* runtime.startTurn(turn); + refuse = true; + const result = yield* Effect.exit( + runtime.startTurn({ + ...turn, + modelSelection: { ...modelSelection, model: "other-model" }, + }), + ); + assert.equal(result._tag, "Failure"); + assert.deepEqual(yield* runtime.executionSelection!(binding), modelSelection); + }).pipe( + Effect.provide( + makeTestLayer({ + state, + idleTimeoutMs: 1000, + startTurn: (input) => + refuse + ? Effect.fail( + new Refusal({ + driver: CODEX_DRIVER, + providerThreadId: input.providerThread.id, + }), + ) + : Effect.void, + }), + ), + ); + }), + ); +} + const fakeHttpServer = HttpServer.HttpServer.of({ address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 43123), serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], @@ -429,6 +527,192 @@ const TestMcpRegistryLayer = Layer.effect( Layer.provide(NodeServices.layer), ); +for (const scenario of [ + "missing-row", + "moved-native", + "missing-evidence", + "current-stop", + "newer-start", +] as const) { + it.effect(`deferred buffer detach retains identity and releases credentials: ${scenario}`, () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + let buffered = false; + let parkProbe = false; + const probeEntered = yield* Deferred.make(); + const probeRelease = yield* Deferred.make(); + const probes: Array = []; + yield* Effect.gen(function* () { + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const eventSink = yield* EventSink.EventSinkV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make(`thread:deferred:${scenario}`); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const runtime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const binding = makeProviderThread({ idAllocator, threadId, providerSessionId, now }); + if (scenario !== "missing-evidence") + yield* runtime.resumeThread({ providerThread: binding, modelSelection, runtimePolicy }); + if (scenario === "moved-native" || scenario === "missing-evidence") { + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ threadId }), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: + scenario === "moved-native" + ? { + ...binding, + nativeThreadRef: { + driver: CODEX_DRIVER, + nativeId: "new-native", + strength: "strong", + }, + providerSessionId: null, + } + : binding, + }, + ], + }); + } + buffered = true; + const token = (yield* Ref.get(mcpConfigs))[0]!.authorizationHeader.replace( + /^Bearer\s+/, + "", + ); + yield* manager.detach({ providerSessionId, threadId, preserveBufferedOutput: true }); + yield* TestClock.adjust("30 millis"); + assert.equal( + (yield* Ref.get(state)).closeCount, + 0, + "owned buffer pins beyond normal idle cap", + ); + assert.isDefined(yield* registry.resolve(token)); + assert.isTrue( + probes.every((nativeId) => nativeId === "native-thread"), + "probe uses original native identity even after projection moves", + ); + assert.isAbove(probes.length, 1); + if (scenario === "newer-start") { + parkProbe = true; + const sweep = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.take(1), + Stream.runDrain, + Effect.forkChild, + ); + yield* Queue.offer((yield* Ref.get(state)).eventQueues.get(providerSessionId)!, { + type: "turn.terminal", + driver: CODEX_DRIVER, + providerThreadId: binding.id, + providerTurnId: ProviderTurnId.make("provider-turn:old-detach"), + runOrdinal: 1, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Deferred.await(probeEntered); + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const runId = idAllocator.derive.run({ threadId, ordinal: 2 }); + const newerBinding = { + ...binding, + nativeThreadRef: { + driver: CODEX_DRIVER, + nativeId: "new-native", + strength: "strong" as const, + }, + }; + yield* runtime.startTurn({ + appThread: (yield* projectionStore.getThreadProjection(threadId)).thread, + threadId, + runId, + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId }), + providerThread: newerBinding, + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId, ordinal: 2 }), + text: "new explicit operation", + attachments: [], + }, + modelSelection, + runtimePolicy, + }); + buffered = false; + yield* Deferred.succeed(probeRelease, undefined); + yield* Fiber.join(sweep); + yield* TestClock.adjust("10 millis"); + assert.isTrue(yield* runtime.hasResidentBinding!(newerBinding)); + assert.deepEqual(yield* runtime.executionSelection!(newerBinding), modelSelection); + assert.equal( + (yield* Ref.get(state)).closeCount, + 0, + "old deferred release cannot close a newer operation", + ); + assert.isDefined(yield* registry.resolve(token)); + return; + } + if (scenario === "current-stop") { + yield* manager.detach({ providerSessionId, threadId }); + } else { + buffered = false; + yield* TestClock.adjust("10 millis"); + } + assert.equal( + (yield* Ref.get(state)).closeCount, + 1, + "drain or explicit Stop actually closes obsolete runtime", + ); + assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); + assert.isUndefined( + yield* registry.resolve(token), + "owner credential is revoked after process release", + ); + }).pipe( + Effect.provide( + makeTestLayer({ + state, + mcpConfigs, + capabilities: ExclusiveCapabilities, + idleTimeoutMs: 10, + maxIdlePinMs: 20, + hasBufferedOutputForThread: (thread) => + Effect.gen(function* () { + probes.push(thread.nativeThreadRef?.nativeId); + if (parkProbe && thread.nativeThreadRef?.nativeId === "native-thread") { + yield* Deferred.succeed(probeEntered, undefined); + yield* Deferred.await(probeRelease); + return false; + } + return buffered && thread.nativeThreadRef?.nativeId === "native-thread"; + }), + }), + ), + ); + }), + ); +} + function makeBrowserAccessProject(projectId: ProjectId): Project { return { id: projectId, diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 786eb5a6bf0a..2f2789267ee4 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1,3 +1,4 @@ +import { modelSelectionsEqual } from "@t3tools/shared/model"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { ModelSelection, @@ -38,6 +39,9 @@ import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import { ProviderAdapterEventStreamError, + ProviderAdapterBufferedOutputError, + ProviderAdapterRunningWorkError, + ProviderAdapterTurnStartError, ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2Error, type ProviderAdapterV2Event, @@ -176,6 +180,7 @@ export interface ProviderSessionManagerV2Shape { * potential re-attach. */ readonly revokeMcpCredential?: boolean; + readonly preserveBufferedOutput?: boolean; }) => Effect.Effect; } @@ -184,7 +189,27 @@ export class ProviderSessionManagerV2 extends Context.Service< ProviderSessionManagerV2Shape >()("t3/orchestration-v2/ProviderSessionManager/ProviderSessionManagerV2") {} +interface ExecutionSelectionEvidence { + readonly providerThread?: OrchestrationV2ProviderThread; + readonly providerThreadId: string; + readonly nativeThreadId: string | null; + readonly selection: ModelSelection | null; + readonly bufferedRunOrdinal?: number; + readonly operation?: symbol | undefined; + readonly runAttemptId?: string; + readonly providerTurnId?: string; + readonly loadKey?: string; +} + interface LiveSessionEntry { + readonly executionSelections: ReadonlyMap; + readonly preservedOutputThreadIds: ReadonlyMap< + ThreadId, + { + readonly binding: OrchestrationV2ProviderThread; + readonly operation: symbol | undefined; + } + >; readonly attachedThreadIds: ReadonlySet; readonly loadedProviderThreadKeyByThread: ReadonlyMap; /** @@ -859,6 +884,49 @@ export const layerWithOptions = ( ), ); + const finishDeferredDetaches = Effect.fnUntraced(function* (entry: LiveSessionEntry) { + const ownedEntry = (yield* Ref.get(sessions)).get( + sessionKey(entry.runtime.providerSessionId), + ); + if (ownedEntry?.runtime !== entry.runtime) return; + for (const [threadId, retained] of ownedEntry.preservedOutputThreadIds) { + const buffered = yield* ( + entry.runtime.hasBufferedOutputForThread?.(retained.binding) ?? Effect.succeed(false) + ); + if (buffered) continue; + yield* Ref.update(sessions, (current) => { + const key = sessionKey(entry.runtime.providerSessionId); + const latest = current.get(key); + if ( + latest?.runtime !== entry.runtime || + latest.preservedOutputThreadIds.get(threadId) !== retained + ) + return current; + const preservedOutputThreadIds = new Map(latest.preservedOutputThreadIds); + preservedOutputThreadIds.delete(threadId); + const executionSelections = new Map(latest.executionSelections); + const evidence = executionSelections.get(threadId); + const attachedThreadIds = new Set(latest.attachedThreadIds); + attachedThreadIds.delete(threadId); + const loadedProviderThreadKeyByThread = new Map(latest.loadedProviderThreadKeyByThread); + loadedProviderThreadKeyByThread.delete(threadId); + if (evidence !== undefined) + executionSelections.set(threadId, { + ...evidence, + selection: null, + operation: undefined, + }); + return new Map(current).set(key, { + ...latest, + executionSelections, + preservedOutputThreadIds, + attachedThreadIds, + loadedProviderThreadKeyByThread, + }); + }); + } + }); + // Annotated to break the releaseIfStillIdle <-> scheduleIdleReleaseInternal // inference cycle introduced by the pin re-arm below. const releaseIfStillIdle = (input: { @@ -876,6 +944,14 @@ export const layerWithOptions = ( ) { return; } + yield* finishDeferredDetaches(entry).pipe( + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.driver-session.deferred-detach-failed", { + providerSessionId: entry.runtime.providerSessionId, + cause, + }), + ), + ); // Capture runtime identity before yielding: a replacement session // can reuse the same providerSessionId while this fiber is parked. const probedRuntime = entry.runtime; @@ -885,10 +961,20 @@ export const layerWithOptions = ( : yield* probedRuntime.hasPendingBackgroundWork.pipe( Effect.catchCause(() => Effect.succeed(false)), ); - if (hasPendingWork) { + const afterProbe = (yield* Ref.get(sessions)).get(key); + if ( + afterProbe?.runtime !== probedRuntime || + afterProbe.busyCount > 0 || + afterProbe.idleGeneration !== input.generation + ) + return; + if (hasPendingWork || afterProbe.preservedOutputThreadIds.size > 0) { const now = yield* Clock.currentTimeMillis; - const pinnedSinceMs = entry.pinnedSinceMs ?? now; - if (now - pinnedSinceMs < maxIdlePinMs) { + const pinnedSinceMs = afterProbe.pinnedSinceMs ?? now; + if ( + now - pinnedSinceMs < maxIdlePinMs || + afterProbe.preservedOutputThreadIds.size > 0 + ) { const shouldContinuePin = yield* Ref.modify(sessions, (latest) => { const latestEntry = latest.get(key); if ( @@ -1266,9 +1352,223 @@ export const layerWithOptions = ( >, ): ProviderAdapterV2SessionRuntime => { const providerSessionId = runtime.providerSessionId; + const selectionEvidence = Effect.fnUntraced(function* (threadId: ThreadId) { + const entry = (yield* Ref.get(sessions)).get(sessionKey(providerSessionId)); + return entry?.runtime === runtime ? entry.executionSelections.get(threadId) : undefined; + }); + const setSelectionEvidence = ( + threadId: ThreadId, + evidence: ExecutionSelectionEvidence, + expectedOperation?: symbol, + ) => + Ref.update(sessions, (current) => { + const key = sessionKey(providerSessionId); + const entry = current.get(key); + if ( + entry?.runtime !== runtime || + (expectedOperation !== undefined && + entry.executionSelections.get(threadId)?.operation !== expectedOperation) + ) + return current; + return new Map(current).set(key, { + ...entry, + executionSelections: new Map(entry.executionSelections).set(threadId, evidence), + }); + }); const subscribeEvents = makeEventSubscription(eventSubscribers); + const observeSelection = Effect.fnUntraced(function* ( + threadId: ThreadId, + operation: Effect.Effect, + evidence: ExecutionSelectionEvidence, + acceptedSelection: ModelSelection | null, + drainOnly = false, + ) { + const token = Symbol(); + const previous = yield* selectionEvidence(threadId); + yield* setSelectionEvidence(threadId, { ...evidence, operation: token }); + const entry = (yield* Ref.get(sessions)).get(sessionKey(providerSessionId)); + const retained = entry?.preservedOutputThreadIds.get(threadId); + if (entry?.runtime === runtime && retained !== undefined) { + const preservedOutputThreadIds = new Map(entry.preservedOutputThreadIds); + if ( + evidence.bufferedRunOrdinal !== undefined && + retained.binding.id === evidence.providerThreadId && + retained.binding.nativeThreadRef?.nativeId === evidence.nativeThreadId + ) { + preservedOutputThreadIds.set(threadId, { ...retained, operation: token }); + } else { + preservedOutputThreadIds.delete(threadId); + } + yield* Ref.update(sessions, (current) => + current.get(sessionKey(providerSessionId)) === entry + ? new Map(current).set(sessionKey(providerSessionId), { + ...entry, + preservedOutputThreadIds, + }) + : current, + ); + } + const result = yield* Effect.exit(operation); + const current = yield* selectionEvidence(threadId); + if (current?.operation === token) { + let selection = Exit.isSuccess(result) ? acceptedSelection : null; + if (drainOnly) { + selection = previous?.selection ?? null; + } else if (selection !== null && runtime.selectionAcknowledged !== undefined) { + if (!(yield* runtime.selectionAcknowledged(selection))) selection = null; + } + if (!drainOnly && Exit.isFailure(result) && !Cause.hasInterrupts(result.cause)) { + const failure = Cause.findErrorOption(result.cause); + const error = + Option.isSome(failure) && Schema.is(ProviderAdapterTurnStartError)(failure.value) + ? failure.value.cause + : Option.getOrUndefined(failure); + const refused = + Schema.is(ProviderAdapterRunningWorkError)(error) || + Schema.is(ProviderAdapterBufferedOutputError)(error); + const sameSelection = + runtime.preservesSelectionOnSameSelectionFailure === true && + previous?.selection != null && + acceptedSelection !== null && + modelSelectionsEqual(previous.selection, acceptedSelection); + if (refused || sameSelection) selection = previous?.selection ?? null; + if (selection === null && evidence.providerThread !== undefined) + selection = yield* ( + runtime.liveExecutionSelection?.(evidence.providerThread) ?? Effect.succeed(null) + ); + } + if ((yield* selectionEvidence(threadId))?.operation === token) { + yield* setSelectionEvidence( + threadId, + { + ...current, + ...(Exit.isFailure(result) && + selection !== null && + previous?.selection != null && + modelSelectionsEqual(selection, previous.selection) + ? previous + : {}), + operation: token, + selection, + }, + token, + ); + } + } + return yield* result; + }); + const protectBufferedOutput = Effect.fnUntraced(function* ( + thread: OrchestrationV2ProviderThread | undefined, + selection?: ModelSelection, + policy?: ProviderAdapterV2RuntimePolicy, + starting = false, + ) { + if (thread === undefined || thread.appThreadId === null) return; + const buffered = yield* ( + runtime.hasBufferedOutputForThread?.(thread) ?? Effect.succeed(false) + ); + if (!buffered) return; + const evidence = yield* selectionEvidence(thread.appThreadId); + const bufferedSelection = yield* ( + runtime.bufferedExecutionSelection?.(thread) ?? Effect.succeed(null) + ); + if ( + starting && + bufferedSelection !== null && + selection !== undefined && + modelSelectionsEqual(bufferedSelection, selection) + ) + return; + if ( + starting && + evidence?.selection !== null && + evidence?.loadKey === + providerThreadLoadKey({ + providerThread: thread, + ...(selection === undefined ? {} : { modelSelection: selection }), + ...(policy === undefined ? {} : { runtimePolicy: policy }), + }) + ) + return; + return yield* new ProviderAdapterBufferedOutputError({ + driver: runtime.driver, + providerThreadId: thread.id, + }); + }); + const rememberBinding = Effect.fnUntraced(function* ( + thread: OrchestrationV2ProviderThread, + ) { + if (thread.appThreadId === null) return; + const previous = yield* selectionEvidence(thread.appThreadId); + if ( + previous?.providerThreadId !== thread.id || + previous.nativeThreadId !== (thread.nativeThreadRef?.nativeId ?? null) + ) { + yield* setSelectionEvidence(thread.appThreadId, { + providerThread: thread, + providerThreadId: thread.id, + nativeThreadId: thread.nativeThreadRef?.nativeId ?? null, + selection: null, + }); + } + }); + const invalidateBinding = (threadId: ThreadId) => + Ref.update(sessions, (current) => { + const key = sessionKey(providerSessionId); + const entry = current.get(key); + if (entry?.runtime !== runtime) return current; + const executionSelections = new Map(entry.executionSelections); + const preservedOutputThreadIds = new Map(entry.preservedOutputThreadIds); + preservedOutputThreadIds.delete(threadId); + const previous = executionSelections.get(threadId); + if (previous !== undefined) + executionSelections.set(threadId, { + ...previous, + selection: null, + operation: undefined, + }); + return new Map(current).set(key, { + ...entry, + executionSelections, + preservedOutputThreadIds, + }); + }); return { ...runtime, + // Only a binding this runtime loaded, resumed or ran has a selection + // that can be uncertain. A thread merely attached when the session + // opened (as a rollback opens one) is still a fresh attachment: its + // next turn applies the selection like any first turn. + hasResidentBinding: (thread) => + Effect.gen(function* () { + const entry = (yield* Ref.get(sessions)).get(sessionKey(providerSessionId)); + return ( + entry?.runtime === runtime && + thread.appThreadId !== null && + thread.providerSessionId === providerSessionId && + entry.executionSelections.has(thread.appThreadId) + ); + }), + executionSelection: (thread) => + Effect.gen(function* () { + const entry = (yield* Ref.get(sessions)).get(sessionKey(providerSessionId)); + if ( + entry?.runtime !== runtime || + thread.appThreadId === null || + thread.providerSessionId !== providerSessionId || + thread.providerInstanceId !== runtime.instanceId || + thread.driver !== runtime.driver || + (thread.nativeThreadRef !== null && + thread.nativeThreadRef.driver !== runtime.driver) || + !entry.attachedThreadIds.has(thread.appThreadId) + ) + return null; + const evidence = yield* selectionEvidence(thread.appThreadId); + return evidence?.providerThreadId === thread.id && + evidence.nativeThreadId === (thread.nativeThreadRef?.nativeId ?? null) + ? evidence.selection + : null; + }), subscribeEvents, events: Stream.unwrap( subscribeEvents.pipe(Effect.map((subscription) => subscription.events)), @@ -1282,7 +1582,16 @@ export const layerWithOptions = ( providerInstanceId: runtime.instanceId, }), ).pipe( + Effect.andThen( + protectBufferedOutput( + input.existingProviderThread, + input.modelSelection, + input.runtimePolicy, + ), + ), + Effect.andThen(invalidateBinding(input.threadId)), Effect.andThen(runtime.ensureThread(input)), + Effect.tap(rememberBinding), Effect.tap((providerThread) => markProviderThreadLoaded({ providerSessionId, @@ -1319,8 +1628,32 @@ export const layerWithOptions = ( isProviderThreadLoaded({ providerSessionId, threadId, providerThreadKey }), ), Effect.flatMap((loaded) => - loaded ? Effect.succeed(input.providerThread) : runtime.resumeThread(input), + Effect.gen(function* () { + const buffered = yield* ( + runtime.bufferedExecutionSelection?.(input.providerThread) ?? + Effect.succeed(null) + ); + return ( + loaded || + (buffered !== null && + input.modelSelection !== undefined && + modelSelectionsEqual(buffered, input.modelSelection)) + ); + }), ), + Effect.flatMap((loaded) => + loaded + ? Effect.succeed(input.providerThread) + : protectBufferedOutput( + input.providerThread, + input.modelSelection, + input.runtimePolicy, + ).pipe( + Effect.andThen(invalidateBinding(threadId)), + Effect.andThen(runtime.resumeThread(input)), + ), + ), + Effect.tap(rememberBinding), Effect.tap((providerThread) => markProviderThreadLoaded({ providerSessionId, @@ -1347,7 +1680,9 @@ export const layerWithOptions = ( providerInstanceId: runtime.instanceId, }), ).pipe( + Effect.andThen(invalidateBinding(input.targetThreadId)), Effect.andThen(runtime.forkThread(input)), + Effect.tap(rememberBinding), Effect.tap((providerThread) => markProviderThreadLoaded({ providerSessionId, @@ -1365,22 +1700,61 @@ export const layerWithOptions = ( ), ), startTurn: (input) => - observeActivity( - providerSessionId, - ensureThreadAttached({ - providerSessionId, - threadId: input.threadId, - providerInstanceId: runtime.instanceId, - }), - ).pipe( - Effect.andThen(observeActivity(providerSessionId, markBusy(providerSessionId))), - Effect.andThen(runtime.startTurn(input)), - Effect.catch((error) => - observeActivity(providerSessionId, markIdle(providerSessionId)).pipe( - Effect.andThen(Effect.fail(error)), + Effect.gen(function* () { + const previous = yield* selectionEvidence(input.threadId); + const reapplySelection = + previous?.runAttemptId !== undefined && previous.selection == null; + return yield* protectBufferedOutput( + input.providerThread, + input.modelSelection, + input.runtimePolicy, + true, + ).pipe( + Effect.andThen( + observeActivity( + providerSessionId, + ensureThreadAttached({ + providerSessionId, + threadId: input.threadId, + providerInstanceId: runtime.instanceId, + }), + ).pipe( + Effect.andThen(observeActivity(providerSessionId, markBusy(providerSessionId))), + Effect.andThen( + observeSelection( + input.threadId, + runtime.startTurn({ ...input, reapplySelection }), + { + providerThread: input.providerThread, + providerThreadId: input.providerThread.id, + nativeThreadId: input.providerThread.nativeThreadRef?.nativeId ?? null, + selection: null, + runAttemptId: input.attemptId, + loadKey: providerThreadLoadKey({ + providerThread: input.providerThread, + modelSelection: input.modelSelection, + runtimePolicy: input.runtimePolicy, + }), + ...(input.message.createdBy === "agent" && + input.message.creationSource === "provider" + ? { bufferedRunOrdinal: input.runOrdinal } + : {}), + }, + input.modelSelection, + runtime.continuationDrainsOutput === true && + input.message.createdBy === "agent" && + input.message.creationSource === "provider", + ), + ), + Effect.catch((error) => + observeActivity(providerSessionId, markIdle(providerSessionId)).pipe( + Effect.andThen(Effect.fail(error)), + ), + ), + ), ), - ), - ), + ); + }), steerTurn: (input) => observeActivity(providerSessionId, touchActivity(providerSessionId)).pipe( Effect.andThen(runtime.steerTurn(input)), @@ -1432,54 +1806,103 @@ export const layerWithOptions = ( ) { stoppedByProvider = true; } - return observeActivity( - entry.runtime.providerSessionId, - event.type === "turn.terminal" - ? markIdle(entry.runtime.providerSessionId) - : touchActivity(entry.runtime.providerSessionId), - ).pipe( - Effect.andThen( - event.type === "provider_session.updated" - ? persistProviderSessionUpdate(entry, event) - : Effect.void, - ), - Effect.andThen( - Effect.gen(function* () { - // Some providers can block before a run subscriber exists - // (project trust, login, or session-switch hooks). Persist - // their runless request artifacts directly so the normal T3 - // request UI can answer them and unblock session setup. - const threadId = sessionScopedRuntimeRequestThreadId(event); - if (threadId !== undefined) { - yield* Effect.gen(function* () { - const current = (yield* Ref.get(sessions)).get( - sessionKey(entry.runtime.providerSessionId), - ); - if (current?.runtime !== entry.runtime) return; - yield* providerEventIngestor - .ingestNormalized({ - providerSessionId: entry.runtime.providerSessionId, - providerInstanceId: entry.runtime.instanceId, - threadId, - event, - }) - .pipe( - Effect.mapError( - (cause) => - new ProviderAdapterEventStreamError({ - driver: entry.runtime.driver, - providerSessionId: entry.runtime.providerSessionId, - cause, - }), + const recordEvidence = Ref.update(sessions, (current) => { + const key = sessionKey(entry.runtime.providerSessionId); + const latest = current.get(key); + if (latest?.runtime !== entry.runtime) return current; + const executionSelections = new Map(latest.executionSelections); + if (event.type === "provider_turn.updated") { + for (const [threadId, evidence] of executionSelections) { + if ( + evidence.providerThreadId === event.providerTurn.providerThreadId && + evidence.runAttemptId === event.providerTurn.runAttemptId + ) { + executionSelections.set(threadId, { + ...evidence, + providerTurnId: event.providerTurn.id, + }); + } + } + } + if (event.type === "turn.terminal") { + for (const [threadId, evidence] of executionSelections) { + if ( + evidence.providerThreadId === event.providerThreadId && + evidence.providerTurnId === event.providerTurnId + ) { + const { bufferedRunOrdinal: _bufferedRunOrdinal, ...completed } = evidence; + executionSelections.set(threadId, completed); + } + } + } + return new Map(current).set(key, { ...latest, executionSelections }); + }); + return recordEvidence + .pipe( + Effect.andThen( + observeActivity( + entry.runtime.providerSessionId, + event.type === "turn.terminal" + ? markIdle(entry.runtime.providerSessionId) + : touchActivity(entry.runtime.providerSessionId), + ), + ), + ) + .pipe( + Effect.andThen( + event.type === "turn.terminal" + ? finishDeferredDetaches(entry).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "orchestration-v2.driver-session.deferred-detach-failed", + { providerSessionId: entry.runtime.providerSessionId, cause }, ), + ), + ) + : Effect.void, + ), + Effect.andThen( + event.type === "provider_session.updated" + ? persistProviderSessionUpdate(entry, event) + : Effect.void, + ), + Effect.andThen( + Effect.gen(function* () { + // Some providers can block before a run subscriber exists + // (project trust, login, or session-switch hooks). Persist + // their runless request artifacts directly so the normal T3 + // request UI can answer them and unblock session setup. + const threadId = sessionScopedRuntimeRequestThreadId(event); + if (threadId !== undefined) { + yield* Effect.gen(function* () { + const current = (yield* Ref.get(sessions)).get( + sessionKey(entry.runtime.providerSessionId), ); - }).pipe(entry.requestEventPermit.withPermits(1)); - return; - } - yield* publishToSubscribers(entry.eventSubscribers, { type: "event", event }); - }), - ), - ); + if (current?.runtime !== entry.runtime) return; + yield* providerEventIngestor + .ingestNormalized({ + providerSessionId: entry.runtime.providerSessionId, + providerInstanceId: entry.runtime.instanceId, + threadId, + event, + }) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapterEventStreamError({ + driver: entry.runtime.driver, + providerSessionId: entry.runtime.providerSessionId, + cause, + }), + ), + ); + }).pipe(entry.requestEventPermit.withPermits(1)); + return; + } + yield* publishToSubscribers(entry.eventSubscribers, { type: "event", event }); + }), + ), + ); }), Effect.exit, Effect.flatMap((exit) => @@ -1660,11 +2083,14 @@ export const layerWithOptions = ( const eventSubscribers = yield* Ref.make< ReadonlyMap> >(new Map()); + const executionSelections = new Map(); const exposedRuntime = decorateRuntime(runtime, eventSubscribers); const now = yield* Clock.currentTimeMillis; const entry: LiveSessionEntry = { attachedThreadIds: new Set([input.threadId]), loadedProviderThreadKeyByThread: new Map(), + executionSelections, + preservedOutputThreadIds: new Map(), mcpCredentialIdByThread: mcpCredentialId === undefined ? new Map() @@ -1776,6 +2202,80 @@ export const layerWithOptions = ( const key = sessionKey(input.providerSessionId); const currentEntry = (yield* Ref.get(sessions)).get(key); let detachedProviderThreads: ReadonlyArray = []; + if (input.preserveBufferedOutput === true && currentEntry !== undefined) { + const evidence = currentEntry.executionSelections.get(input.threadId); + const projectedBinding = + evidence?.providerThread ?? + (yield* projectionStore.getThreadRecords(input.threadId, [ + "providerThreads", + ])).providerThreads.find( + (thread) => + thread.id === evidence?.providerThreadId || + thread.providerSessionId === input.providerSessionId, + ); + const binding = + projectedBinding === undefined + ? undefined + : { + ...projectedBinding, + providerSessionId: input.providerSessionId, + nativeThreadRef: + evidence?.nativeThreadId == null + ? projectedBinding.nativeThreadRef + : { + driver: currentEntry.runtime.driver, + nativeId: evidence.nativeThreadId, + strength: "strong" as const, + }, + }; + const buffered = + currentEntry.executionSelections.get(input.threadId)?.bufferedRunOrdinal !== + undefined; + const owned = + binding === undefined + ? false + : yield* ( + currentEntry.runtime.hasBufferedOutputForThread?.(binding) ?? + Effect.succeed(false) + ); + if ((buffered || owned) && binding !== undefined) { + const latest = (yield* Ref.get(sessions)).get(key); + if ( + latest?.runtime !== currentEntry.runtime || + latest.executionSelections.get(input.threadId)?.operation !== evidence?.operation + ) + return; + yield* Ref.update(sessions, (current) => { + const active = current.get(key); + if ( + active?.runtime !== currentEntry.runtime || + active.executionSelections.get(input.threadId)?.operation !== + evidence?.operation + ) + return current; + return new Map(current).set(key, { + ...active, + preservedOutputThreadIds: new Map(active.preservedOutputThreadIds).set( + input.threadId, + { binding, operation: evidence?.operation }, + ), + }); + }); + yield* scheduleIdleRelease(input.providerSessionId); + return; + } + const running = + binding === undefined + ? false + : yield* ( + currentEntry.runtime.hasPendingBackgroundWorkForThread?.(binding) ?? + Effect.succeed(false) + ); + if (running) + return yield* new ProviderAdapterRunningWorkError({ + driver: currentEntry.runtime.driver, + }); + } if (currentEntry?.supportsMultipleProviderThreads === true) { const projection = yield* Effect.option( projectionStore.getThreadRecords(input.threadId, [ @@ -1829,6 +2329,20 @@ export const layerWithOptions = ( entry.loadedProviderThreadKeyByThread, ); loadedProviderThreadKeyByThread.delete(input.threadId); + const executionSelections = new Map(entry.executionSelections); + const preservedOutputThreadIds = new Map(entry.preservedOutputThreadIds); + if (input.preserveBufferedOutput === true) { + const evidence = executionSelections.get(input.threadId); + if (evidence !== undefined) + executionSelections.set(input.threadId, { + ...evidence, + selection: null, + operation: undefined, + }); + } else { + executionSelections.delete(input.threadId); + } + preservedOutputThreadIds.delete(input.threadId); // For a plain (workspace-change) detach, the credential id stays // recorded: the thread may re-attach and reuse it, and // releaseEntry revokes it when the provider process finally goes @@ -1844,6 +2358,8 @@ export const layerWithOptions = ( : entry.mcpCredentialIdByThread; const updatedEntry = { ...entry, + executionSelections, + preservedOutputThreadIds, attachedThreadIds, loadedProviderThreadKeyByThread, mcpCredentialIdByThread, diff --git a/apps/server/src/orchestration-v2/ProviderSwitchService.ts b/apps/server/src/orchestration-v2/ProviderSwitchService.ts index d96fb8fa3680..c717495443e8 100644 --- a/apps/server/src/orchestration-v2/ProviderSwitchService.ts +++ b/apps/server/src/orchestration-v2/ProviderSwitchService.ts @@ -43,6 +43,8 @@ export interface ProviderSwitchServiceV2Shape { "thread" | "providerSessions" | "providerThreads" >; readonly targetModelSelection: ModelSelection; + readonly executionSelection?: ModelSelection | null; + readonly freshAttachment?: boolean; }) => Effect.Effect; } @@ -66,12 +68,21 @@ export const layer: Layer.Layer< Effect.gen(function* () { const adapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; return ProviderSwitchServiceV2.of({ - plan: ({ projection, targetModelSelection }) => + plan: ({ projection, targetModelSelection, executionSelection, freshAttachment }) => Effect.gen(function* () { - const current = projection.thread.modelSelection; - const instanceChanged = current.instanceId !== targetModelSelection.instanceId; - const modelChanged = current.model !== targetModelSelection.model; - const getMetadata = (instanceId: typeof current.instanceId) => + const current = + executionSelection === undefined + ? projection.thread.modelSelection + : executionSelection; + const currentInstanceId = + current?.instanceId ?? + projection.providerThreads.find( + (thread) => thread.id === projection.thread.activeProviderThreadId, + )?.providerInstanceId ?? + projection.thread.providerInstanceId; + const instanceChanged = currentInstanceId !== targetModelSelection.instanceId; + const modelChanged = current?.model !== targetModelSelection.model; + const getMetadata = (instanceId: ModelSelection["instanceId"]) => adapters.getMetadata !== undefined ? adapters.getMetadata(instanceId) : adapters.get(instanceId).pipe( @@ -86,16 +97,23 @@ export const layer: Layer.Layer< ), ), ); - const currentInstance = yield* Effect.option(getMetadata(current.instanceId)); + const currentInstance = yield* Effect.option(getMetadata(currentInstanceId)); const targetInstance = yield* Effect.option(getMetadata(targetModelSelection.instanceId)); const targetAdapter = yield* Effect.option(adapters.get(targetModelSelection.instanceId)); const currentSessions = projection.providerSessions - .filter((session) => session.providerInstanceId === current.instanceId) + .filter((session) => session.providerInstanceId === currentInstanceId) .toSorted( (left, right) => DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), ); - const currentSession = currentSessions.find(isLiveProviderSession); + const activeSessionId = projection.providerThreads.find( + (thread) => thread.id === projection.thread.activeProviderThreadId, + )?.providerSessionId; + const currentSession = currentSessions.find( + (session) => + isLiveProviderSession(session) && + (executionSelection === undefined || session.id === activeSessionId), + ); // Negotiated capabilities describe the provider, not the dead // process; the newest record still reports what the instance // supports after its session stops. @@ -105,11 +123,12 @@ export const layer: Layer.Layer< const currentProviderThread = projection.providerThreads.find( (thread) => thread.id === projection.thread.activeProviderThreadId && - thread.providerInstanceId === current.instanceId && + thread.providerInstanceId === currentInstanceId && thread.nativeThreadRef !== null, ); const selectionTransition = - current.instanceId === targetModelSelection.instanceId && + current !== null && + currentInstanceId === targetModelSelection.instanceId && !modelSelectionsEqual(current, targetModelSelection) && Option.isSome(targetAdapter) && Option.isSome(currentInstance) && @@ -120,51 +139,74 @@ export const layer: Layer.Layer< sessionCapabilities: negotiatedCapabilities ?? currentInstance.value.capabilities, }) : undefined; + const canResumeUnknown = + currentProviderThread !== undefined && + Option.isSome(currentInstance) && + Option.isSome(targetInstance) && + currentInstance.value.driver === targetInstance.value.driver && + currentInstance.value.continuationKey === targetInstance.value.continuationKey; + const unknownTransition = (() => { + if (currentInstanceId === targetModelSelection.instanceId) { + return { + type: + freshAttachment === true ? ("reuse" as const) : ("restart_and_resume" as const), + }; + } + return { + type: canResumeUnknown + ? ("restart_and_resume" as const) + : ("create_with_handoff" as const), + }; + })(); const transition = - Option.isNone(targetInstance) || Option.isNone(targetAdapter) + Option.isNone(targetInstance) || + Option.isNone(targetAdapter) || + !targetInstance.value.enabled ? ({ type: "reject", reason: "The target provider instance is unavailable.", } as const) - : decideProviderSessionTransition({ - current: - Option.isNone(currentInstance) || - (currentSession === undefined && currentProviderThread === undefined) - ? null - : { - driver: currentInstance.value.driver, - continuationIdentity: { - driverKind: currentInstance.value.driver, - continuationKey: currentInstance.value.continuationKey, + : current === null + ? unknownTransition + : decideProviderSessionTransition({ + current: + Option.isNone(currentInstance) || + (currentSession === undefined && currentProviderThread === undefined) + ? null + : { + driver: currentInstance.value.driver, + continuationIdentity: { + driverKind: currentInstance.value.driver, + continuationKey: currentInstance.value.continuationKey, + }, + modelSelection: current, + runtimeMode: projection.thread.runtimeMode, + interactionMode: projection.thread.interactionMode, + workspace: + currentSession?.cwd ?? + projection.thread.worktreePath ?? + "", + capabilities: + negotiatedCapabilities ?? currentInstance.value.capabilities, }, - modelSelection: current, - runtimeMode: projection.thread.runtimeMode, - interactionMode: projection.thread.interactionMode, - workspace: - currentSession?.cwd ?? - projection.thread.worktreePath ?? - "", - capabilities: - negotiatedCapabilities ?? currentInstance.value.capabilities, - }, - target: { - driver: targetInstance.value.driver, - continuationIdentity: { - driverKind: targetInstance.value.driver, - continuationKey: targetInstance.value.continuationKey, + target: { + driver: targetInstance.value.driver, + continuationIdentity: { + driverKind: targetInstance.value.driver, + continuationKey: targetInstance.value.continuationKey, + }, + modelSelection: targetModelSelection, + runtimeMode: projection.thread.runtimeMode, + interactionMode: projection.thread.interactionMode, + workspace: + projection.thread.worktreePath ?? + currentSession?.cwd ?? + "", + capabilities: targetInstance.value.capabilities, + available: targetInstance.value.enabled, }, - modelSelection: targetModelSelection, - runtimeMode: projection.thread.runtimeMode, - interactionMode: projection.thread.interactionMode, - workspace: - projection.thread.worktreePath ?? - currentSession?.cwd ?? - "", - capabilities: targetInstance.value.capabilities, - available: targetInstance.value.enabled, - }, - ...(selectionTransition === undefined ? {} : { selectionTransition }), - }); + ...(selectionTransition === undefined ? {} : { selectionTransition }), + }); if (transition.type === "reject") { return yield* new ProviderSwitchPlanError({ threadId: projection.thread.id, diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index eba830319aaf..c4d52b1109b8 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -177,12 +177,14 @@ it.effect( updatedAt: now, lastError: null, }; + let ownedBuffer = false; const runtime: ProviderAdapterV2SessionRuntime = { instanceId: providerInstanceId, driver, providerSessionId: oldSessionId, providerSession, events: Stream.empty, + hasBufferedOutputForThread: () => Effect.sync(() => ownedBuffer), ensureThread: () => Effect.die("unused ensureThread"), resumeThread: () => Effect.die("unused resumeThread"), startTurn: () => Effect.die("unused startTurn"), @@ -320,5 +322,33 @@ it.effect( assert.equal(interrupted?.providerSessionId, oldSessionId); assert.equal(interrupted?.id, providerThreadId); assert.equal(interrupted?.nativeThreadRef?.nativeId, "native-thread:restart-session"); + yield* Ref.set(interruptedThread, null); + yield* Ref.update(projection, (current) => ({ + ...current, + providerThreads: current.providerThreads.map((thread) => ({ + ...thread, + providerSessionId: oldSessionId, + })), + })); + yield* Effect.gen(function* () { + const control = yield* ProviderTurnControlService.ProviderTurnControlServiceV2; + const stop = control.interrupt({ + threadId, + providerSessionId: oldSessionId, + providerThreadId, + providerTurnId, + }); + yield* stop; + assert.isNull( + yield* Ref.get(interruptedThread), + "terminal turn without owned work is not interrupted again", + ); + ownedBuffer = true; + yield* stop; + assert.isNotNull( + yield* Ref.get(interruptedThread), + "Stop reaches a terminal turn's owned buffer even without running work", + ); + }).pipe(Effect.provide(controlLayer)); }), ); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index 3a80e2af5d85..9ee33847268d 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -179,7 +179,11 @@ export const layer: Layer.Layer< if ( loaded.providerTurn.status !== "running" && (session.value.hasPendingBackgroundWorkForThread === undefined || - !(yield* session.value.hasPendingBackgroundWorkForThread(loaded.providerThread))) + !(yield* session.value.hasPendingBackgroundWorkForThread(loaded.providerThread))) && + !(yield* ( + session.value.hasBufferedOutputForThread?.(loaded.providerThread) ?? + Effect.succeed(false) + )) ) return; yield* session.value.interruptTurn({ diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 4ea41f4ca9b8..11c44640f297 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -37,6 +37,7 @@ import { import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; import { ProviderAdapterTurnStartError, + ProviderAdapterBufferedOutputError, type ProviderAdapterV2Error, type ProviderAdapterV2HistoricalContext, type ProviderAdapterV2SessionRuntime, @@ -593,7 +594,11 @@ export const layer: Layer.Layer< Effect.gen(function* () { const loaded = yield* Effect.result(load); if (loaded._tag === "Success") return loaded.success; - if (input.willRetry === true) return yield* loaded.failure; + if ( + input.willRetry === true || + Schema.is(ProviderAdapterBufferedOutputError)(loaded.failure) + ) + return yield* loaded.failure; yield* settleStartFailure({ signal: "provider-thread-load-failure", title: "Provider turn failed to start", @@ -602,176 +607,202 @@ export const layer: Layer.Layer< return undefined; }); let effectiveHandoffs = handoffs; - const loadedProviderThread = yield* Effect.gen(function* () { - if (nativeForkTransfer !== undefined) { - const sourceProjection = yield* projectionStore.getThreadRecords( - nativeForkTransfer.sourceThreadId, - ["runs", "providerThreads", "attempts", "providerTurns"], - ); - const sourceRun = sourceProjection.runs.find( - (candidate) => candidate.id === nativeForkTransfer.sourcePoint.runId, - ); - const sourceProviderThread = sourceProjection.providerThreads.find( - (candidate) => candidate.id === sourceRun?.providerThreadId, - ); - const sourceAttempt = sourceProjection.attempts.find( - (candidate) => candidate.id === sourceRun?.activeAttemptId, + const bindingResult = yield* Effect.result( + Effect.gen(function* () { + if (nativeForkTransfer !== undefined) { + const sourceProjection = yield* projectionStore.getThreadRecords( + nativeForkTransfer.sourceThreadId, + ["runs", "providerThreads", "attempts", "providerTurns"], + ); + const sourceRun = sourceProjection.runs.find( + (candidate) => candidate.id === nativeForkTransfer.sourcePoint.runId, + ); + const sourceProviderThread = sourceProjection.providerThreads.find( + (candidate) => candidate.id === sourceRun?.providerThreadId, + ); + const sourceAttempt = sourceProjection.attempts.find( + (candidate) => candidate.id === sourceRun?.activeAttemptId, + ); + const sourceProviderTurn = sourceProjection.providerTurns.find( + (candidate) => + candidate.id === sourceAttempt?.providerTurnId || + candidate.runAttemptId === sourceAttempt?.id, + ); + if (sourceRun === undefined || sourceProviderThread === undefined) { + return yield* new ProviderTurnStartError({ + runId, + cause: `Native fork transfer ${nativeForkTransfer.id} has no source provider execution.`, + }); + } + return yield* loadFromProvider( + session.forkThread({ + sourceProviderThread, + sourceProviderTurns: sourceProjection.providerTurns, + targetThreadId: projection.thread.id, + modelSelection: run.modelSelection, + runtimePolicy: resolvedRuntimePolicy, + ...(sourceProviderTurn === undefined + ? {} + : { providerTurnId: sourceProviderTurn.id }), + }), + ); + } + if (providerThread.nativeThreadRef === null) { + // Hand the run's provider thread to the adapter so it adopts this + // row's identity when attaching native state. An adapter that mints + // its own row instead leaves two live rows per app thread, and + // `activeProviderThreadId` then flaps between them on every update. + return yield* loadFromProvider( + session.ensureThread({ + threadId: projection.thread.id, + modelSelection: run.modelSelection, + runtimePolicy: resolvedRuntimePolicy, + providerSessionId, + existingProviderThread: providerThread, + }), + ); + } + const uncertainDelivery = projection.contextHandoffs.some( + (handoff) => + handoff.toProviderThreadId === providerThread.id && + handoff.delivery?.nativeThreadId === providerThread.nativeThreadRef?.nativeId && + handoff.delivery?.status === "pending", ); - const sourceProviderTurn = sourceProjection.providerTurns.find( - (candidate) => - candidate.id === sourceAttempt?.providerTurnId || - candidate.runAttemptId === sourceAttempt?.id, + const resumed = yield* Effect.result( + uncertainDelivery + ? Effect.fail( + new ProviderAdapterTurnStartError({ + driver: session.driver, + threadId: projection.thread.id, + providerThreadId: providerThread.id, + runId, + cause: "Uncertain native history injection", + }), + ) + : session.resumeThread({ + providerThread, + threadId: projection.thread.id, + modelSelection: run.modelSelection, + runtimePolicy: resolvedRuntimePolicy, + }), ); - if (sourceRun === undefined || sourceProviderThread === undefined) { - return yield* new ProviderTurnStartError({ - runId, - cause: `Native fork transfer ${nativeForkTransfer.id} has no source provider execution.`, - }); + if (resumed._tag === "Success") { + return resumed.success; } - return yield* loadFromProvider( - session.forkThread({ - sourceProviderThread, - sourceProviderTurns: sourceProjection.providerTurns, - targetThreadId: projection.thread.id, - modelSelection: run.modelSelection, - runtimePolicy: resolvedRuntimePolicy, - ...(sourceProviderTurn === undefined - ? {} - : { providerTurnId: sourceProviderTurn.id }), - }), - ); - } - if (providerThread.nativeThreadRef === null) { - // Hand the run's provider thread to the adapter so it adopts this - // row's identity when attaching native state. An adapter that mints - // its own row instead leaves two live rows per app thread, and - // `activeProviderThreadId` then flaps between them on every update. - return yield* loadFromProvider( + + if (Schema.is(ProviderAdapterBufferedOutputError)(resumed.failure)) + return yield* resumed.failure; + yield* Effect.logWarning("Provider resume failed; attempting a fresh native session", { + driver: session.driver, + providerThreadId: providerThread.id, + runId, + reason: uncertainDelivery ? "uncertain_history_delivery" : "resume_failed", + errorTag: resumed.failure._tag, + }); + const replacement = yield* loadFromProvider( session.ensureThread({ threadId: projection.thread.id, modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, providerSessionId, - existingProviderThread: providerThread, + // The native ref is dropped so the adapter binds a fresh native + // session instead of retrying the resume that just failed, while + // still adopting this row's identity. + existingProviderThread: { ...providerThread, nativeThreadRef: null }, }), ); - } - const uncertainDelivery = projection.contextHandoffs.some( - (handoff) => - handoff.toProviderThreadId === providerThread.id && - handoff.delivery?.nativeThreadId === providerThread.nativeThreadRef?.nativeId && - handoff.delivery?.status === "pending", - ); - const resumed = yield* Effect.result( - uncertainDelivery - ? Effect.fail( - new ProviderAdapterTurnStartError({ - driver: session.driver, - threadId: projection.thread.id, - providerThreadId: providerThread.id, - runId, - cause: "Uncertain native history injection", - }), - ) - : session.resumeThread({ - providerThread, - threadId: projection.thread.id, - modelSelection: run.modelSelection, - runtimePolicy: resolvedRuntimePolicy, - }), - ); - if (resumed._tag === "Success") { - return resumed.success; - } - - yield* Effect.logWarning("Provider resume failed; attempting a fresh native session", { - driver: session.driver, - providerThreadId: providerThread.id, - runId, - reason: uncertainDelivery ? "uncertain_history_delivery" : "resume_failed", - errorTag: resumed.failure._tag, - }); - const replacement = yield* loadFromProvider( - session.ensureThread({ + if (replacement === undefined) return undefined; + const transferId = yield* idAllocator.allocate.contextTransfer({ + sourceThreadId: projection.thread.id, + targetThreadId: projection.thread.id, + type: "provider_resume_fallback", + }); + const createdAt = yield* DateTime.now; + const handoff = yield* contextHandoffService.prepareProviderHandoff({ threadId: projection.thread.id, - modelSelection: run.modelSelection, - runtimePolicy: resolvedRuntimePolicy, - providerSessionId, - // The native ref is dropped so the adapter binds a fresh native - // session instead of retrying the resume that just failed, while - // still adopting this row's identity. - existingProviderThread: { ...providerThread, nativeThreadRef: null }, - }), - ); - if (replacement === undefined) return undefined; - const transferId = yield* idAllocator.allocate.contextTransfer({ - sourceThreadId: projection.thread.id, - targetThreadId: projection.thread.id, - type: "provider_resume_fallback", - }); - const createdAt = yield* DateTime.now; - const handoff = yield* contextHandoffService.prepareProviderHandoff({ - threadId: projection.thread.id, - targetRunId: run.id, - transferId, - fromProviderThreadIds: [providerThread.id], - toProviderThreadId: providerThread.id, - fromProviderInstanceId: providerThread.providerInstanceId, - toProviderInstanceId: run.providerInstanceId, - coveredRunOrdinals: { from: 1, to: Math.max(1, run.ordinal - 1) }, - strategy: "full_thread_summary", - runs: projection.runs, - items: (yield* projectionStore.getTurnStartHistory(input.threadId)).filter( - (item) => - item.runId === null || - projection.runs.some( - (source) => source.id === item.runId && source.ordinal < run.ordinal, - ), - ), - createdAt, - }); - effectiveHandoffs = [handoff, ...effectiveHandoffs]; - yield* eventSink.write({ - events: [ - { - id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), - type: "context-handoff.updated", - threadId: projection.thread.id, - runId: run.id, - providerInstanceId: run.providerInstanceId, - occurredAt: createdAt, - payload: handoff, - }, - { - id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), - type: "context-transfer.updated", - threadId: projection.thread.id, - runId: run.id, - providerInstanceId: run.providerInstanceId, - occurredAt: createdAt, - payload: { - id: transferId, - type: "provider_handoff", - sourceThreadId: projection.thread.id, - targetThreadId: projection.thread.id, - sourcePoint: { threadId: projection.thread.id }, - basePoint: null, - sourceProviderInstanceId: providerThread.providerInstanceId, - targetProviderInstanceId: run.providerInstanceId, - targetRunId: run.id, - status: "resolved_portable", - resolution: { strategy: "portable_context", contextHandoffId: handoff.id }, - createdBy: "system", - error: null, - createdAt, - updatedAt: createdAt, - consumedAt: null, + targetRunId: run.id, + transferId, + fromProviderThreadIds: [providerThread.id], + toProviderThreadId: providerThread.id, + fromProviderInstanceId: providerThread.providerInstanceId, + toProviderInstanceId: run.providerInstanceId, + coveredRunOrdinals: { from: 1, to: Math.max(1, run.ordinal - 1) }, + strategy: "full_thread_summary", + runs: projection.runs, + items: (yield* projectionStore.getTurnStartHistory(input.threadId)).filter( + (item) => + item.runId === null || + projection.runs.some( + (source) => source.id === item.runId && source.ordinal < run.ordinal, + ), + ), + createdAt, + }); + effectiveHandoffs = [handoff, ...effectiveHandoffs]; + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "context-handoff.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: createdAt, + payload: handoff, }, - }, - ], + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "context-transfer.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: createdAt, + payload: { + id: transferId, + type: "provider_handoff", + sourceThreadId: projection.thread.id, + targetThreadId: projection.thread.id, + sourcePoint: { threadId: projection.thread.id }, + basePoint: null, + sourceProviderInstanceId: providerThread.providerInstanceId, + targetProviderInstanceId: run.providerInstanceId, + targetRunId: run.id, + status: "resolved_portable", + resolution: { strategy: "portable_context", contextHandoffId: handoff.id }, + createdBy: "system", + error: null, + createdAt, + updatedAt: createdAt, + consumedAt: null, + }, + }, + ], + }); + return replacement; + }), + ); + if (bindingResult._tag === "Failure") { + if (!Schema.is(ProviderAdapterBufferedOutputError)(bindingResult.failure)) + return yield* bindingResult.failure; + yield* settleRunBeforeStart({ + signal: "provider-buffered-output-owned", + status: "failed", + now: yield* DateTime.now, + providerInstanceId: run.providerInstanceId, + itemProviderThreadId: providerThread.id, + item: { + type: "error", + title: "Provider output must be delivered first", + failure: makeProviderFailure({ + cause: bindingResult.failure, + class: "provider_error", + message: "Buffered provider output must be delivered before changing its binding.", + }), + }, }); - return replacement; - }); + return; + } + const loadedProviderThread = bindingResult.success; // The last attempt already failed the run. if (loadedProviderThread === undefined) return; if (!(yield* isCurrentAttemptInStatus("starting"))) { diff --git a/apps/server/src/orchestration-v2/ReadmissionBuffer.integration.test.ts b/apps/server/src/orchestration-v2/ReadmissionBuffer.integration.test.ts new file mode 100644 index 000000000000..ffe9ee417838 --- /dev/null +++ b/apps/server/src/orchestration-v2/ReadmissionBuffer.integration.test.ts @@ -0,0 +1,562 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + ThreadId, + TurnItemId, + type ModelSelection, + type OrchestrationV2DomainEvent, + type OrchestrationV2ProviderThread, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { EventSinkV2 } from "./EventSink.ts"; +import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; +import { + ProviderAdapterTurnStartError, + type ProviderAdapterV2Event, + type ProviderAdapterV2Shape, + type ProviderAdapterV2TurnInput, +} from "./ProviderAdapter.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const driver = ProviderDriverKind.make("codex"); +const instanceId = ProviderInstanceId.make("codex"); +const selection = (effort: string): ModelSelection => ({ + instanceId, + model: "same-model", + options: [{ id: "effort", value: effort }], +}); +const A = selection("low"); +const B = selection("medium"); +const C = selection("high"); +const capabilities = CodexProviderCapabilitiesV2; + +for (const failure of ["none", "before", "after", "race", "imported"] as const) { + const uncertain = failure === "before" || failure === "after"; + it.effect( + failure === "imported" + ? "freshly admits an imported native binding without run history" + : `preserves shared buffered output through ${failure} partial failure`, + () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`readmission-buffer-${failure}`); + const queues = new Map>(); + const buffers = new Map(); + const calls: ProviderAdapterV2TurnInput[] = []; + const opens: string[] = []; + const closes: string[] = []; + const consumed: Array<{ text: string; selection: ModelSelection; delivery: string }> = []; + const loaded = new Map(); + let inspectStarting: + | ((input: ProviderAdapterV2TurnInput) => Effect.Effect) + | undefined; + const adapter: ProviderAdapterV2Shape = { + driver, + instanceId, + getCapabilities: () => Effect.succeed(capabilities), + planSelectionTransition: ({ current, target }) => + Effect.succeed({ + type: + current.options?.[0]?.value === "low" && target.options?.[0]?.value === "high" + ? "restart_session" + : "apply_on_next_turn", + }), + openSession: (input) => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + queues.set(input.providerSessionId, events); + opens.push(input.providerSessionId); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + closes.push(input.providerSessionId); + }), + ); + const now = yield* DateTime.now; + const bindings = new Set(); + const bind = (thread: OrchestrationV2ProviderThread) => + Effect.sync(() => { + bindings.add(thread.id); + if (!loaded.has(thread.id)) loaded.set(thread.id, input.modelSelection); + return thread; + }); + return { + driver, + instanceId, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: input.modelSelection.model, + capabilities, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + hasPendingBackgroundWork: Effect.sync(() => + [...bindings].some((id) => buffers.has(id)), + ), + hasPendingBackgroundWorkForThread: () => Effect.succeed(false), + hasBufferedOutputForThread: (thread) => Effect.sync(() => buffers.has(thread.id)), + ensureThread: ({ threadId, existingProviderThread }) => + bind({ + ...existingProviderThread!, + id: + existingProviderThread?.id ?? + ProviderThreadId.make(`provider-thread:${threadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { + driver, + nativeId: `native:${threadId}`, + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }), + resumeThread: ({ providerThread }) => bind(providerThread), + startTurn: (turn) => + Effect.gen(function* () { + assert.isDefined(inspectStarting); + yield* inspectStarting!(turn); + calls.push(turn); + if (turn.message.messageId === "message:reject") { + if (failure === "after") + loaded.set(turn.providerThread.id, turn.modelSelection); + return yield* new ProviderAdapterTurnStartError({ + driver, + threadId: turn.threadId, + providerThreadId: turn.providerThread.id, + runId: turn.runId, + cause: "Controlled partial selection failure.", + }); + } + const providerTurnId = ProviderTurnId.make(`provider-turn:${turn.attemptId}`); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: providerTurnId, + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: { + driver, + nativeId: `native:${turn.attemptId}`, + strength: "strong", + }, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: yield* DateTime.now, + completedAt: null, + }, + }); + if (turn.message.creationSource === "provider") { + const buffer = buffers.get(turn.providerThread.id)!; + assert.isDefined(buffer, "the original runtime owns the output"); + consumed.push({ ...buffer, delivery: turn.message.messageId }); + buffers.delete(turn.providerThread.id); + const at = yield* DateTime.now; + yield* Queue.offer(events, { + type: "turn_item.updated", + driver, + turnItem: { + id: TurnItemId.make(`item:buffer:${turn.message.messageId}`), + threadId: turn.threadId, + runId: turn.runId, + nodeId: turn.rootNodeId, + providerThreadId: turn.providerThread.id, + providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: turn.runOrdinal * 100 + 1, + status: "completed", + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + type: "assistant_message", + text: buffer.text, + messageId: MessageId.make(`assistant:${turn.message.messageId}`), + streaming: false, + }, + }); + } else { + loaded.set(turn.providerThread.id, turn.modelSelection); + } + }), + steerTurn: () => Effect.void, + interruptTurn: () => Effect.void, + respondToRuntimeRequest: () => Effect.void, + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }; + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const manager = yield* ProviderSessionManagerV2; + inspectStarting = (input) => + Effect.gen(function* () { + const runtime = Option.getOrThrow( + yield* manager.get(input.providerThread.providerSessionId!).pipe(Effect.orDie), + ); + assert.isNull( + yield* runtime.executionSelection!(input.providerThread), + "a requested start has no acknowledgement yet", + ); + }); + const threadId = ThreadId.make("thread:buffer-owner"); + const siblingId = ThreadId.make("thread:buffer-sibling"); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const send = (id: ThreadId, name: string, modelSelection?: ModelSelection) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(name), + threadId: id, + messageId: MessageId.make(`message:${name}`), + text: name, + attachments: [], + ...(modelSelection ? { modelSelection } : {}), + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const start = Effect.fnUntraced(function* (id: ThreadId, name: string) { + const running = yield* watch( + (event) => + event.threadId === id && + event.type === "provider-turn.updated" && + event.payload.status === "running", + ); + yield* send(id, name); + yield* worker.drain(); + yield* Fiber.join(running); + }); + const finish = Effect.fnUntraced(function* (id: ThreadId) { + const projection = yield* orchestrator.getThreadProjection(id); + const run = projection.runs.at(-1)!; + const turn = projection.providerTurns.find( + (candidate) => candidate.runAttemptId === run.activeAttemptId, + )!; + const binding = projection.providerThreads.find( + (thread) => thread.id === run.providerThreadId, + )!; + const waiting = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === run.id && + event.payload.status === "waiting", + ); + const events = queues.get(binding.providerSessionId!)!; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal: run.ordinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(waiting); + yield* worker.drain(); + }); + for (const id of [threadId, siblingId]) { + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create:${id}`), + threadId: id, + projectId: ProjectId.make("project:buffer"), + title: "Buffer", + modelSelection: A, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + } + if (failure === "imported") { + const sink = yield* EventSinkV2; + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make("imported-binding"), + type: "provider-thread.updated", + threadId, + providerInstanceId: instanceId, + occurredAt: now, + payload: { + id: ProviderThreadId.make("imported-native-binding"), + driver, + providerInstanceId: instanceId, + providerSessionId: null, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { + driver, + nativeId: "imported-native-with-no-runs", + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "not_loaded", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }, + ], + }); + } + yield* start(threadId, "first"); + if (failure === "imported") + assert.equal( + calls[0]!.providerThread.nativeThreadRef?.nativeId, + "imported-native-with-no-runs", + ); + yield* finish(threadId); + if (failure === "imported") { + const imported = calls[0]!.providerThread; + const resident = Option.getOrThrow(yield* manager.get(imported.providerSessionId!)); + assert.deepEqual(yield* resident.executionSelection!(imported), A); + assert.equal(opens.length, 1); + return; + } + yield* start(siblingId, "sibling"); + assert.equal(opens.length, 1, "both threads genuinely share residency"); + const owner = (yield* orchestrator.getThreadProjection(threadId)).providerThreads[0]!; + const oldSession = owner.providerSessionId!; + const oldRuntime = Option.getOrThrow(yield* manager.get(oldSession)); + assert.deepEqual(yield* oldRuntime.executionSelection!(owner), A); + assert.isNull( + yield* oldRuntime.executionSelection!({ + ...owner, + id: ProviderThreadId.make("another-binding"), + }), + ); + assert.isNull( + yield* oldRuntime.executionSelection!({ + ...owner, + nativeThreadRef: { driver, nativeId: "another-native-thread", strength: "strong" }, + }), + ); + if (uncertain) { + yield* send(threadId, "reject", B); + yield* worker.drain(); + const failed = (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)!; + assert.equal(failed.status, "failed"); + assert.isNull(yield* oldRuntime.executionSelection!(owner)); + buffers.set(owner.id, { + text: "Owned output after ambiguous application", + selection: loaded.get(owner.id)!, + }); + } else { + if (failure === "race") yield* send(threadId, "admitted-before-buffer", B); + buffers.set(owner.id, { text: "Owned original output", selection: A }); + if (failure === "race") { + yield* worker.drain(); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)!.status, + "failed", + ); + assert.deepEqual(yield* oldRuntime.executionSelection!(owner), A); + assert.isFalse( + calls.some((call) => call.message.messageId === "message:admitted-before-buffer"), + ); + } + } + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("set-c"), + threadId, + modelSelection: C, + }); + yield* worker.drain(); + assert.isTrue( + Option.isSome(yield* manager.get(oldSession)), + "selection detach retains the owner runtime", + ); + assert.isTrue(buffers.has(owner.id)); + const attemptedNewExecution = yield* Effect.exit(send(threadId, "blocked-following")); + assert.equal(attemptedNewExecution._tag, "Failure"); + const notification = { + type: "message.dispatch" as const, + commandId: CommandId.make("deliver"), + threadId, + messageId: MessageId.make("message:deliver"), + text: "Do not execute this placeholder", + attachments: [], + dispatchMode: { type: "queue_after_active" as const }, + createdBy: "agent" as const, + creationSource: "provider" as const, + notification: { + source: { kind: "background_task" as const }, + outcome: "updated" as const, + summary: "Owned output", + }, + }; + if (uncertain) { + assert.equal( + (yield* Effect.exit(orchestrator.dispatch(notification)))._tag, + "Failure", + ); + assert.isTrue(buffers.has(owner.id)); + assert.deepEqual(consumed, []); + assert.equal( + calls.filter((call) => call.message.messageId === "message:reject").length, + 1, + ); + } else { + const item = yield* watch( + (event) => + event.type === "turn-item.updated" && + event.payload.type === "assistant_message" && + event.payload.text === "Owned original output", + ); + yield* orchestrator.dispatch(notification); + yield* worker.drain(); + yield* Fiber.join(item); + assert.deepEqual(consumed, [ + { text: "Owned original output", selection: A, delivery: "message:deliver" }, + ]); + assert.equal(calls.at(-1)!.providerThread.providerSessionId, oldSession); + assert.deepEqual(calls.at(-1)!.modelSelection, A); + assert.deepEqual( + (yield* orchestrator.getThreadProjection(threadId)).thread.modelSelection, + C, + ); + yield* finish(threadId); + yield* start(threadId, "following"); + assert.equal(opens.length, 2); + assert.deepEqual(calls.at(-1)!.modelSelection, C); + const replacement = calls.at(-1)!.providerThread; + const replacementRuntime = Option.getOrThrow( + yield* manager.get(replacement.providerSessionId!), + ); + assert.deepEqual(yield* replacementRuntime.executionSelection!(replacement), C); + assert.isNull(yield* oldRuntime.executionSelection!(owner)); + const delivered = yield* oldRuntime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal" && event.runOrdinal === 1), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const firstTurn = (yield* orchestrator.getThreadProjection(threadId)) + .providerTurns[0]!; + yield* Queue.offer(queues.get(oldSession)!, { + type: "turn.terminal", + driver, + providerThreadId: owner.id, + providerTurnId: firstTurn.id, + runOrdinal: 1, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(delivered); + assert.deepEqual( + yield* replacementRuntime.executionSelection!(replacement), + C, + "late output from old residency cannot certify or relabel its replacement", + ); + assert.isNull(yield* oldRuntime.executionSelection!(owner)); + } + yield* finish(siblingId); + yield* start(siblingId, "sibling-following"); + assert.equal(calls.at(-1)!.providerThread.providerSessionId, oldSession); + assert.isFalse( + closes.includes(oldSession), + "the unrelated shared thread remains alive", + ); + const sibling = calls.at(-1)!.providerThread; + yield* finish(siblingId); + assert.deepEqual(yield* oldRuntime.executionSelection!(sibling), A); + yield* oldRuntime.resumeThread({ + providerThread: sibling, + threadId: siblingId, + modelSelection: B, + runtimePolicy: calls.at(-1)!.runtimePolicy, + }); + assert.isNull( + yield* oldRuntime.executionSelection!(sibling), + "a resume request is not an execution acknowledgement", + ); + yield* orchestrator.dispatch({ + type: "provider-session.detach", + commandId: CommandId.make("stop-sibling"), + threadId: siblingId, + providerSessionId: oldSession, + reason: "Explicit Stop", + }); + yield* worker.drain(); + assert.isNull(yield* oldRuntime.executionSelection!(sibling)); + if (uncertain) { + yield* TestClock.adjust("5 hours"); + assert.isTrue( + Option.isSome(yield* manager.get(oldSession)), + "blocked owned output survives the normal idle-pin cap", + ); + assert.isTrue(buffers.has(owner.id)); + } + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: `readmission-buffer-${failure}` }, + makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + }), + ), + ); +} diff --git a/apps/server/src/orchestration-v2/ReadmissionFailure.integration.test.ts b/apps/server/src/orchestration-v2/ReadmissionFailure.integration.test.ts new file mode 100644 index 000000000000..addd779c186d --- /dev/null +++ b/apps/server/src/orchestration-v2/ReadmissionFailure.integration.test.ts @@ -0,0 +1,396 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + ThreadId, + type ModelSelection, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { + ProviderAdapterTurnStartError, + type ProviderAdapterV2Event, + type ProviderAdapterV2Shape, + type ProviderAdapterV2TurnInput, +} from "./ProviderAdapter.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const driver = ProviderDriverKind.make("codex"); +const instanceId = ProviderInstanceId.make("codex"); +const selection = (effort: string): ModelSelection => ({ + instanceId, + model: "same-model", + options: [{ id: "effort", value: effort }], +}); +const A = selection("low"); +const B = selection("medium"); +const C = selection("high"); +const capabilities = { + ...CodexProviderCapabilitiesV2, + sessions: { + ...CodexProviderCapabilitiesV2.sessions, + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: false, + }, +}; + +it.effect( + "requires fresh admission after either partial-start outcome without replaying the failed prompt", + () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("admission-execution-evidence"); + const results = []; + for (const applyBeforeFailure of [false, true]) { + const eventQueues = new Map>(); + const closed = new Set(); + let loaded = A; + let pending = false; + const calls: ProviderAdapterV2TurnInput[] = []; + const providerTurns: string[] = []; + const plans: Array<{ current: ModelSelection; target: ModelSelection }> = []; + let opens = 0; + const adapter: ProviderAdapterV2Shape = { + driver, + instanceId, + getCapabilities: () => Effect.succeed(capabilities), + planSelectionTransition: ({ current, target }) => + Effect.sync(() => { + plans.push({ current, target }); + return { + type: + current.options?.[0]?.value === "low" && target.options?.[0]?.value === "high" + ? "restart_session" + : "apply_on_next_turn", + }; + }), + openSession: (input) => + Effect.gen(function* () { + opens += 1; + loaded = input.modelSelection; + const events = yield* Queue.unbounded(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + closed.add(input.providerSessionId); + }), + ); + const now = yield* DateTime.now; + return { + driver, + instanceId, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: input.modelSelection.model, + capabilities, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + hasPendingBackgroundWork: Effect.sync(() => pending), + ensureThread: ({ threadId, existingProviderThread }) => + Effect.succeed({ + ...existingProviderThread!, + id: + existingProviderThread?.id ?? + ProviderThreadId.make(`provider-thread:${threadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native:evidence", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => + Effect.gen(function* () { + calls.push(turn); + eventQueues.set(turn.attemptId, events); + if (turn.message.text === "reject") { + // Both outcomes obey the current error type: it does not state whether + // a selection-changing operation completed before the request failed. + if (applyBeforeFailure) loaded = turn.modelSelection; + return yield* new ProviderAdapterTurnStartError({ + driver, + threadId: turn.threadId, + providerThreadId: turn.providerThread.id, + runId: turn.runId, + cause: "controlled request rejection", + }); + } + loaded = turn.modelSelection; + const id = ProviderTurnId.make(`provider-turn:${turn.attemptId}`); + providerTurns.push(id); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id, + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: { + driver, + nativeId: `native:${turn.attemptId}`, + strength: "strong", + }, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: yield* DateTime.now, + completedAt: null, + }, + }); + }), + steerTurn: () => Effect.void, + interruptTurn: () => Effect.void, + respondToRuntimeRequest: () => Effect.void, + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }; + const result = yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("thread:execution-evidence"); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const send = (name: string, modelSelection?: ModelSelection) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(name), + threadId, + messageId: MessageId.make(`message:${name}`), + text: name, + attachments: [], + ...(modelSelection ? { modelSelection } : {}), + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:execution-evidence"), + title: "Evidence", + modelSelection: A, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const running = yield* watch( + (event) => + event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* send("first"); + yield* worker.drain(); + yield* Fiber.join(running); + const initial = yield* orchestrator.getThreadProjection(threadId); + const first = initial.runs[0]!; + const turn = initial.providerTurns.find( + (candidate) => candidate.runAttemptId === first.activeAttemptId, + )!; + const waiting = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === first.id && + event.payload.status === "waiting", + ); + const events = eventQueues.get(first.activeAttemptId!)!; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal: first.ordinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(waiting); + yield* worker.drain(); + yield* TestClock.adjust("1 second"); + yield* send("reject", B); + yield* worker.drain(); + const failed = yield* orchestrator.getThreadProjection(threadId); + const rejected = failed.runs.at(-1)!; + assert.equal(rejected.status, "failed"); + assert.isNotNull(rejected.startedAt); + const rejectedAttempt = failed.attempts.find( + (candidate) => candidate.id === rejected.activeAttemptId, + )!; + assert.isNotNull(rejectedAttempt.startedAt); + assert.equal( + providerTurns.length, + 1, + "the adapter emitted no provider turn for the rejected request", + ); + pending = true; + const blocked = yield* Effect.exit(send("blocked-replacement", C)); + assert.equal( + blocked._tag, + "Failure", + "unknown selection cannot replace a runtime with pending work", + ); + assert.equal(opens, 1); + assert.equal(closed.size, 0); + assert.equal(calls.length, 2); + pending = false; + const actualBeforeSet = loaded; + const terminalLooking = failed.runs.findLast( + (run) => + run.providerThreadId === failed.thread.activeProviderThreadId && + run.startedAt !== null, + ); + assert.deepEqual(terminalLooking?.modelSelection, B); + const ownedTurns = failed.providerTurns.filter( + (candidate) => candidate.runAttemptId === rejectedAttempt.id, + ); + const evidence = { + desired: failed.thread.modelSelection, + runs: failed.runs.map((run) => ({ + ordinal: run.ordinal, + selection: run.modelSelection, + status: run.status, + started: run.startedAt !== null, + activeAttemptOrdinal: failed.attempts.find( + (attempt) => attempt.id === run.activeAttemptId, + )?.attemptOrdinal, + })), + attempts: failed.attempts.map((attempt) => ({ + ordinal: attempt.attemptOrdinal, + status: attempt.status, + started: attempt.startedAt !== null, + nativeThreadId: attempt.nativeThreadId, + })), + providerTurns: failed.providerTurns.map((candidate) => ({ + ordinal: candidate.ordinal, + status: candidate.status, + native: candidate.nativeTurnRef, + })), + sessions: failed.providerSessions.map((session) => ({ + model: session.model, + status: session.status, + instanceId: session.providerInstanceId, + })), + bindings: failed.providerThreads.map((thread) => ({ + native: thread.nativeThreadRef, + status: thread.status, + instanceId: thread.providerInstanceId, + hasSession: thread.providerSessionId !== null, + })), + }; + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("set-c"), + threadId, + modelSelection: C, + }); + yield* worker.drain(); + const resumed = yield* watch( + (event) => + event.type === "provider-turn.updated" && + event.payload.status === "running" && + event.payload.runAttemptId !== first.activeAttemptId, + ); + yield* send("following"); + yield* worker.drain(); + yield* Fiber.join(resumed); + const admitted = yield* orchestrator.getThreadProjection(threadId); + assert.equal(opens, 2, "uncertain residency must not be reused"); + assert.deepEqual( + calls.map((call) => call.message.messageId), + ["message:first", "message:reject", "message:following"], + ); + assert.isTrue(calls.at(-1)!.message.text.endsWith("following")); + assert.deepEqual(calls.at(-1)!.modelSelection, C); + assert.equal(admitted.runs.find((run) => run.id === rejected.id)!.status, "failed"); + assert.equal(admitted.runs.length, 3); + const oldSessionId = calls[0]!.providerThread.providerSessionId!; + const newSessionId = calls.at(-1)!.providerThread.providerSessionId!; + assert.notEqual(oldSessionId, newSessionId); + assert.isTrue(closed.has(oldSessionId)); + assert.isFalse(closed.has(newSessionId)); + return { + applyBeforeFailure, + actualBeforeSet, + inferred: terminalLooking!.modelSelection, + adapterEmittedProviderTurns: providerTurns.length, + syntheticRejectedTurns: ownedTurns, + opens, + calls: calls.map((call) => call.modelSelection), + plans, + evidence, + }; + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: "admission-execution-evidence" }, + makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + results.push(result); + } + // Native failed-turn ids include run-derived identifiers; they are identical in + // these deterministic scenarios. No sleeps or absence inferred from timeouts. + assert.deepEqual(results[0]!.evidence, results[1]!.evidence); + assert.deepEqual(results[0]!.actualBeforeSet, A); + assert.deepEqual(results[1]!.actualBeforeSet, B); + for (const result of results) { + assert.isFalse( + result.plans.some( + (plan) => + plan.current.options?.[0]?.value === "medium" && + plan.target.options?.[0]?.value === "high", + ), + "failed B is not trustworthy execution evidence", + ); + } + }), + ), +); diff --git a/apps/server/src/orchestration-v2/ReadmissionNontransitive.integration.test.ts b/apps/server/src/orchestration-v2/ReadmissionNontransitive.integration.test.ts new file mode 100644 index 000000000000..2dd586bf30f2 --- /dev/null +++ b/apps/server/src/orchestration-v2/ReadmissionNontransitive.integration.test.ts @@ -0,0 +1,529 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + NodeId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + ThreadId, + type ModelSelection, + type OrchestrationV2DomainEvent, + type OrchestrationV2ProviderCapabilities, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { EventSinkV2 } from "./EventSink.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { + type ProviderAdapterV2Event, + type ProviderAdapterV2Shape, + type ProviderAdapterV2TurnInput, +} from "./ProviderAdapter.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const driver = ProviderDriverKind.make("codex"); +const instanceId = ProviderInstanceId.make("codex"); +const A: ModelSelection = { + instanceId, + model: "model-a", + options: [{ id: "effort", value: "low" }], +}; +const B: ModelSelection = { + instanceId, + model: "model-b", + options: [{ id: "effort", value: "high" }], +}; +const C: ModelSelection = { + instanceId, + model: "model-c", + options: [{ id: "effort", value: "high" }], +}; +const pooled: OrchestrationV2ProviderCapabilities = CodexProviderCapabilitiesV2; +// Same shape SelectionRestart.integration.test.ts uses for its restart_session cases. +const exclusive: OrchestrationV2ProviderCapabilities = { + ...CodexProviderCapabilitiesV2, + sessions: { + ...CodexProviderCapabilitiesV2.sessions, + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: false, + }, +}; +const caps = { pooled, exclusive } as const; +type Caps = keyof typeof caps; +// Non-transitive but contract-valid: A->B and B->C apply in session, A->C needs restart. +const applyOnNextTurn = (current: ModelSelection, target: ModelSelection) => + current.model === target.model || + (current.model === "model-a" && target.model === "model-b") || + (current.model === "model-b" && target.model === "model-c"); + +interface Log { + readonly opens: Array<{ sessionId: string; model: string }>; + readonly attempts: Array<{ + sessionId: string; + opened: string; + requested: string; + requestedEffort: unknown; + compatible: boolean; + }>; + interrupts: number; + closes: number; + readonly closed: Set; +} + +const makeAdapter = ( + capsName: Caps, + log: Log, + cwd: string, + queues: Map>, +): ProviderAdapterV2Shape => ({ + instanceId, + driver, + getCapabilities: () => Effect.succeed(caps[capsName]), + planSelectionTransition: ({ current, target }) => + Effect.succeed({ + type: applyOnNextTurn(current, target) ? "apply_on_next_turn" : "restart_session", + }), + openSession: (input) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Queue.unbounded(); + log.opens.push({ sessionId: input.providerSessionId, model: input.modelSelection.model }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + log.closes += 1; + log.closed.add(input.providerSessionId); + }), + ); + return { + instanceId, + driver, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: input.modelSelection.model, + capabilities: caps[capsName], + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + ensureThread: ({ threadId }) => + Effect.succeed({ + id: ProviderThreadId.make(`provider-thread:${threadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => + Effect.gen(function* () { + log.attempts.push({ + sessionId: input.providerSessionId, + opened: input.modelSelection.model, + requested: turn.modelSelection.model, + requestedEffort: turn.modelSelection.options, + compatible: applyOnNextTurn(input.modelSelection, turn.modelSelection), + }); + queues.set(turn.attemptId, events); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: { driver, nativeId: `native:${turn.attemptId}`, strength: "strong" }, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: yield* DateTime.now, + completedAt: null, + }, + }); + }), + steerTurn: () => Effect.void, + interruptTurn: () => + Effect.sync(() => { + log.interrupts += 1; + }), + respondToRuntimeRequest: () => Effect.void, + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), +}); + +type Scenario = + | { readonly name: "steer-set-send"; readonly caps: Caps } + | { + readonly name: "queued"; + readonly caps: Caps; + readonly kind: "notification" | "completion" | "user"; + readonly order: "terminal-first" | "detach-first"; + }; + +const scenarios: Scenario[] = [ + { name: "steer-set-send", caps: "exclusive" }, + { name: "steer-set-send", caps: "pooled" }, + { name: "queued", caps: "exclusive", kind: "notification", order: "terminal-first" }, + { name: "queued", caps: "pooled", kind: "notification", order: "terminal-first" }, + { name: "queued", caps: "exclusive", kind: "completion", order: "terminal-first" }, + { name: "queued", caps: "pooled", kind: "completion", order: "terminal-first" }, + { name: "queued", caps: "exclusive", kind: "user", order: "terminal-first" }, + { name: "queued", caps: "pooled", kind: "user", order: "terminal-first" }, +]; + +for (const scenario of scenarios.flatMap((entry) => [ + { ...entry, equalTime: false }, + { ...entry, equalTime: true }, +])) { + const kind = scenario.name === "queued" ? `${scenario.name}-${scenario.kind}` : scenario.name; + const title = `${kind}-${scenario.caps}-${scenario.equalTime ? "equal-time" : "ordered"}`; + it.effect(`preserves queued selection and residency: ${title}`, () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`queued-admission-${title}`); + const log: Log = { opens: [], attempts: [], interrupts: 0, closes: 0, closed: new Set() }; + const queues = new Map>(); + const adapter = makeAdapter(scenario.caps, log, cwd, queues); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const sink = yield* EventSinkV2; + const threadId = ThreadId.make("thread:queued-admission"); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const runningFor = (attemptId: string) => + watch( + (event) => + event.type === "provider-turn.updated" && + event.payload.status === "running" && + event.payload.runAttemptId === attemptId, + ); + const finish = Effect.fnUntraced(function* ( + runId: string, + attemptId: string, + runOrdinal: number, + ) { + if (!scenario.equalTime) yield* TestClock.adjust("1 second"); + const projection = yield* orchestrator.getThreadProjection(threadId); + const turn = projection.providerTurns.find( + (candidate) => candidate.runAttemptId === attemptId, + )!; + const waiting = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === runId && + event.payload.status === "waiting", + ); + const events = queues.get(attemptId)!; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(waiting); + yield* worker.drain(); + yield* orchestrator.resumeQueuedRuns; + yield* worker.drain(); + }); + const tick = scenario.equalTime ? Effect.void : TestClock.adjust("1 second"); + + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:queued-admission"), + title: "Queued admission", + modelSelection: A, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const firstRunning = yield* watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("first"), + threadId, + messageId: MessageId.make("message:first"), + text: "first", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(firstRunning); + const first = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + assert.equal(log.attempts.length, 1); + + if (scenario.name === "steer-set-send") { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("desired"), + threadId, + messageId: MessageId.make("message:desired"), + text: "use B next turn", + attachments: [], + modelSelection: B, + dispatchMode: { type: "steer_active", targetRunId: first.id }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* tick; + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("set-c"), + threadId, + modelSelection: C, + }); + // Deliver the already-running turn's terminal before draining the + // selection command's valid detach, which closes exclusive residency. + yield* tick; + yield* finish(first.id, first.activeAttemptId!, first.ordinal); + yield* tick; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("following"), + threadId, + messageId: MessageId.make("message:following"), + text: "following", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + } else { + const messageId = MessageId.make("message:queued"); + const taskId = NodeId.make("task:queued"); + const firstInput = { runId: first.id, rootNodeId: first.rootNodeId! }; + if (scenario.kind === "completion") { + const projection = yield* orchestrator.getThreadProjection(threadId); + const parentRun = projection.runs.find((run) => run.id === first.id)!; + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make("queued:cohort"), + type: "run.updated", + threadId, + runId: first.id, + occurredAt: now, + payload: { + ...parentRun, + delegatedCompletion: { + disposition: "open", + nextGeneration: 2, + delivery: { generation: 1, messageId, taskIds: [taskId] }, + }, + }, + }, + { + id: EventId.make("queued:task"), + type: "subagent.updated", + threadId, + runId: first.id, + nodeId: taskId, + occurredAt: now, + payload: { + id: taskId, + threadId, + runId: first.id, + parentNodeId: firstInput.rootNodeId, + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId: instanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Background work", + title: "Queued completion", + model: null, + completionWake: "settled_only", + completionDelivery: { state: "claimed", observedByRunId: null }, + status: "completed", + result: "done", + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }, + ], + }); + } + const automatic = scenario.kind !== "user"; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("queued"), + threadId, + messageId, + text: "queued", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: automatic ? "agent" : "user", + creationSource: automatic ? "server" : "web", + ...(automatic ? {} : { modelSelection: A }), + ...(scenario.kind === "notification" + ? { + notification: { + source: { kind: "background_task" as const }, + outcome: "updated" as const, + summary: "Background activity updated", + }, + } + : {}), + ...(scenario.kind === "completion" + ? { + delegatedCompletion: { + parentRunId: first.id, + generation: 1, + taskIds: [taskId], + }, + } + : {}), + }); + yield* worker.drain(); + const queued = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + assert.deepEqual(queued.modelSelection, A); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("desired"), + threadId, + messageId: MessageId.make("message:desired"), + text: "use B next turn", + attachments: [], + modelSelection: B, + dispatchMode: { type: "steer_active", targetRunId: first.id }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* tick; + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("set-c"), + threadId, + modelSelection: C, + }); + yield* tick; + if (scenario.order === "detach-first") yield* worker.drain(); + + const queuedRunning = yield* runningFor(queued.activeAttemptId!); + yield* finish(first.id, first.activeAttemptId!, first.ordinal); + assert.equal( + log.attempts.length, + 2, + "both starts reached the adapter before the drained outbox boundary", + ); + assert.isFalse( + log.closed.has(log.attempts[1]!.sessionId), + "the current runtime survives the old detach", + ); + yield* Fiber.join(queuedRunning); + assert.deepEqual( + (yield* orchestrator.getThreadProjection(threadId)).thread.modelSelection, + automatic ? C : A, + ); + assert.isFalse( + log.closed.has(log.attempts[1]!.sessionId), + "queued residency survives old detach, including equal timestamps", + ); + if (scenario.caps === "exclusive") + assert.isTrue( + log.closed.has(log.attempts[0]!.sessionId), + "the old effect still releases its own runtime", + ); + assert.equal(log.attempts[1]!.requested, A.model); + assert.deepEqual(log.attempts[1]!.requestedEffort, A.options); + yield* finish(queued.id, queued.activeAttemptId!, queued.ordinal); + yield* tick; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("following"), + threadId, + messageId: MessageId.make("message:following"), + text: "implicit following", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "agent", + creationSource: "server", + }); + yield* worker.drain(); + assert.equal(log.attempts.at(-1)!.requested, automatic ? C.model : A.model); + assert.deepEqual( + log.attempts.at(-1)!.requestedEffort, + automatic ? C.options : A.options, + ); + } + const incompatible = log.attempts.filter((attempt) => !attempt.compatible); + assert.deepEqual( + incompatible, + [], + "every turn must run on a session whose loaded selection permits it", + ); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: `queued-admission-${title}` }, + makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + }), + ), + ); +} diff --git a/apps/server/src/orchestration-v2/SelectionNotificationClaude.integration.test.ts b/apps/server/src/orchestration-v2/SelectionNotificationClaude.integration.test.ts new file mode 100644 index 000000000000..86ae1cfa8487 --- /dev/null +++ b/apps/server/src/orchestration-v2/SelectionNotificationClaude.integration.test.ts @@ -0,0 +1,326 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + ClaudeSettings, + CommandId, + MessageId, + ProjectId, + ThreadId, + type ModelSelection, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { CLAUDE_DEFAULT_INSTANCE_ID, makeClaudeAdapterV2 } from "./Adapters/ClaudeAdapterV2.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { layer as idAllocatorLayer, IdAllocatorV2 } from "./IdAllocator.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import type { ProviderContinuationRequest } from "./ProviderContinuationRequests.ts"; +import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const NATIVE = "native-buffer-policy-claude"; +const A: ModelSelection = { + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-sonnet-4-6", + options: [{ id: "effort", value: "high" }], +}; +const B: ModelSelection = { ...A, options: [{ id: "effort", value: "low" }] }; +const settings = Schema.decodeSync(ClaudeSettings)({}); +const frame = (value: unknown) => value as SDKMessage; +const result = (id: number, text: string, wake = false) => + frame({ + type: "result", + subtype: "success", + duration_ms: 10, + duration_api_ms: 10, + is_error: false, + num_turns: 1, + result: text, + stop_reason: "end_turn", + total_cost_usd: 0, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: `00000000-0000-4000-8000-${String(id).padStart(12, "0")}`, + session_id: NATIVE, + ...(wake ? { origin: { kind: "task-notification" } } : {}), + }); +const started = frame({ + type: "system", + subtype: "task_started", + task_id: "build", + description: "npm run build", + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000000001", + session_id: NATIVE, +}); +const finished = frame({ + type: "system", + subtype: "task_notification", + task_id: "build", + status: "completed", + output_file: "/tmp/build.log", + summary: "Build finished", + uuid: "00000000-0000-4000-8000-000000000003", + session_id: NATIVE, +}); +for (const scenario of [ + "opaque-notification-only", + "failed-later-turn", + "known-subagent-blocks", + "known-subagent-later-turn", + "replacement-query-later-turn", +] as const) { + it.effect(`selection-notification probe real Claude v2: ${scenario}`, () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`selection-notification-buffer-${scenario}`); + const sdk = yield* Queue.unbounded(); + let currentSdk = sdk; + const offers = yield* Queue.unbounded(); + const threadId = ThreadId.make(`thread:selection-notification-buffer:${scenario}`); + let opens = 0; + let closes = 0; + let prompts = 0; + const adapter = yield* Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings, + environment: {}, + fileSystem, + attachmentsDir: yield* fileSystem.makeTempDirectoryScoped({ + prefix: "selection-notification-buffer-", + }), + path: yield* Path.Path, + idAllocator: yield* IdAllocatorV2, + continuationRequests: { + offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), + }, + queryRunner: { + allocateSessionId: Effect.succeed(NATIVE), + open: () => + Effect.gen(function* () { + const messages = opens === 0 ? sdk : yield* Queue.unbounded(); + opens++; + currentSdk = messages; + return { + messages: Stream.fromQueue(messages), + offer: () => + Effect.sync(() => { + prompts++; + }), + setModel: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(messages).pipe( + Effect.andThen( + Effect.sync(() => { + closes++; + }), + ), + ), + }; + }), + forkSession: () => Effect.die("unused fork"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const manager = yield* ProviderSessionManagerV2; + const projection = orchestrator.getThreadProjection(threadId); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const send = (name: string, modelSelection?: ModelSelection) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(name), + threadId, + messageId: MessageId.make(`message:${name}`), + text: name, + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + ...(modelSelection === undefined ? {} : { modelSelection }), + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:selection-notification-buffer"), + title: "Review2 buffer", + modelSelection: A, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const firstStarted = yield* watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* send("first"); + yield* worker.drain(); + yield* Fiber.join(firstStarted); + const firstTerminal = yield* watch( + (event) => + event.type === "run.updated" && + (event.payload.status === "waiting" || event.payload.status === "completed"), + ); + const knownSubagent = scenario.startsWith("known-subagent"); + yield* Queue.offer( + sdk, + knownSubagent ? frame({ ...started, task_type: "local_agent" }) : started, + ); + yield* Queue.offer(sdk, result(2, "Kicked off.")); + yield* Fiber.join(firstTerminal); + yield* worker.drain(); + const binding = (yield* projection).providerThreads[0]!; + const runtime = Option.getOrThrow(yield* manager.get(binding.providerSessionId!)); + // The task finishes; Claude clears the roster but never dequeues the + // notification into a native wake turn (no assistant/user/result). + yield* Queue.offer(sdk, finished); + const subscription = yield* runtime.subscribeEvents!; + const cleared = yield* subscription.events.pipe( + Stream.filter( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.pendingBackgroundTasks?.length === 0, + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* Queue.offer( + sdk, + frame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000000010", + session_id: NATIVE, + }), + ); + yield* Fiber.join(cleared); + yield* worker.drain(); + assert.equal(yield* runtime.hasBufferedOutputForThread!(binding), knownSubagent); + assert.equal(yield* Queue.size(offers), knownSubagent ? 1 : 0); + assert.isNotNull(yield* runtime.bufferedExecutionSelection!(binding)); + if (scenario === "known-subagent-blocks") { + const change = yield* Effect.exit(send("change", B)); + yield* worker.drain(); + assert.isTrue( + change._tag === "Failure" || (yield* projection).runs.at(-1)?.status === "failed", + ); + assert.equal(opens, 1, "known-subagent output prevents query replacement"); + assert.equal(closes, 0); + assert.isTrue(yield* runtime.hasBufferedOutputForThread!(binding)); + return; + } + const same = yield* Effect.exit( + send("same", scenario === "replacement-query-later-turn" ? B : A), + ); + yield* worker.drain(); + yield* Queue.offer( + currentSdk, + frame({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "Answering the user." }], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000020", + session_id: NATIVE, + }), + ); + const sameCompleted = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.ordinal === 2 && + ["completed", "failed", "waiting"].includes(event.payload.status), + ); + const completion = result(8, "Answered with the build result."); + yield* Queue.offer( + currentSdk, + scenario === "failed-later-turn" + ? frame({ + ...completion, + subtype: "error_during_execution", + is_error: true, + errors: ["turn failed"], + }) + : completion, + ); + yield* Fiber.join(sameCompleted); + yield* worker.drain(); + assert.equal(same._tag, "Success"); + if (scenario !== "opaque-notification-only") { + if (scenario === "failed-later-turn") { + assert.equal((yield* projection).runs.at(-1)?.status, "failed"); + } + assert.isNotNull( + yield* runtime.bufferedExecutionSelection!(binding), + "only a completed turn on the producing query without owned output releases replay state", + ); + assert.equal(opens, scenario === "replacement-query-later-turn" ? 2 : 1); + assert.equal(yield* runtime.hasBufferedOutputForThread!(binding), knownSubagent); + assert.equal(yield* Queue.size(offers), knownSubagent ? 1 : 0); + return; + } + assert.isNull( + yield* runtime.bufferedExecutionSelection!(binding), + "later same-query completion releases notification replay state", + ); + assert.equal(yield* Queue.size(offers), 0); + const changed = yield* watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + const change = yield* Effect.exit(send("change", B)); + assert.equal(change._tag, "Success"); + yield* worker.drain(); + yield* Fiber.join(changed); + assert.equal(opens, 2); + assert.equal(closes, 1); + assert.equal(prompts, 3); + assert.equal( + change._tag, + "Success", + "a later selection change is not blocked forever by a notification-only buffer", + ); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: `selection-notification-buffer-${scenario}` }, + makeSingleLayer(adapter), + ), + ), + ); + }), + ), + ); +} diff --git a/apps/server/src/orchestration-v2/SelectionRecoveryAcp.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRecoveryAcp.integration.test.ts new file mode 100644 index 000000000000..d78a784d3326 --- /dev/null +++ b/apps/server/src/orchestration-v2/SelectionRecoveryAcp.integration.test.ts @@ -0,0 +1,397 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CommandId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as AcpErrors from "effect-acp/errors"; +import * as AcpSessionRuntime from "../provider/acp/AcpSessionRuntime.ts"; +import { makeAcpAdapterV2, AcpProviderCapabilitiesV2 } from "./Adapters/AcpAdapterV2.ts"; +import { makeReplayServerConfig } from "./Adapters/CodexAdapterV2.testkit.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { layer as idAllocatorLayer, IdAllocatorV2 } from "./IdAllocator.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import { + ProviderContinuationRequests, + type ProviderContinuationRequest, +} from "./ProviderContinuationRequests.ts"; +import { workerLive } from "./ProviderContinuationService.ts"; +import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +type Runtime = AcpSessionRuntime.AcpSessionRuntime["Service"]; +const encodeLog = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +for (const scenario of [ + "before-application", + "partial-model", + "error-response", + "indeterminate", + "config-response", + "mixed-prefix", +] as const) { + it.live(`ACP ${scenario} output retains producing attribution without certifying a drain`, () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`acp-selection-${scenario}`); + const fileSystem = yield* FileSystem.FileSystem.pipe(Effect.provide(NodeServices.layer)); + const requestLogPath = `${cwd}/acp-requests.jsonl`; + const modelRequests = () => + fileSystem + .readFileString(requestLogPath) + .pipe( + Effect.map( + (text) => + text.split("\n").filter((line) => line.includes('"configId":"model"')).length, + ), + ); + const instanceId = ProviderInstanceId.make("acp-test"); + const driver = ProviderDriverKind.make("acp-test"); + const offers = yield* Queue.unbounded(); + const logs: Array = []; + const logger = Logger.make(({ message }) => { + logs.push(message); + }); + let update: Parameters[0] | undefined; + let armed = false; + let opens = 0; + let prompts = 0; + const modelCalls: Array<{ model: string; force: boolean | undefined }> = []; + let modeCalls = 0; + const reject = () => + new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: "fixture configuration rejected", + operation: "receive-response", + }); + const buffer = () => + update!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `Retained ${scenario} output` }, + }, + }); + const adapter = yield* Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return makeAcpAdapterV2({ + instanceId, + crypto: yield* Crypto.Crypto, + fileSystem: yield* FileSystem.FileSystem, + idAllocator: yield* IdAllocatorV2, + selfInvocation: yield* resolveSelfInvocation(), + serverConfig: yield* makeReplayServerConfig(`acp-selection-${scenario}`), + continuationRequests: { + offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), + }, + flavor: { + driver, + capabilities: AcpProviderCapabilitiesV2, + deferFinalizeForBackgroundWork: true, + enablePostSettleContinuation: true, + sessionModeForPolicy: () => (scenario === "config-response" ? undefined : "code"), + makeRuntime: (input) => + Effect.gen(function* () { + opens++; + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: { + command: process.execPath, + args: [ + new URL("../../scripts/acp-mock-agent.ts", import.meta.url).pathname, + ], + cwd: input.cwd, + env: { + T3_ACP_SESSION_LIFECYCLE: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }, + }, + authMethodId: "test", + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ), + ), + ); + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime.pipe( + Effect.provide(context), + ); + return { + ...runtime, + handleSessionUpdate: (handler) => + Effect.sync(() => { + update = handler; + }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), + prompt: (input) => + Effect.sync(() => { + prompts++; + }).pipe(Effect.andThen(runtime.prompt(input))), + setModel: (model, force) => + Effect.gen(function* () { + modelCalls.push({ model, force }); + if ( + armed && + (scenario === "before-application" || scenario === "mixed-prefix") + ) + yield* buffer(); + if (armed && scenario === "error-response") return yield* reject(); + yield* runtime.setModel(model, force); + if (armed && scenario === "indeterminate") + return yield* new AcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/set_config_option", + cause: "fixture lost response", + }); + }), + setConfigOption: (id, value, force) => + armed && scenario === "config-response" + ? Effect.fail(reject()) + : runtime.setConfigOption(id, value, force), + setMode: (mode, force) => + Effect.gen(function* () { + modeCalls++; + if (armed) { + return yield* reject(); + } + return yield* runtime.setMode(mode, force); + }), + }; + }), + }, + }); + }).pipe(Effect.provide(Layer.merge(NodeServices.layer, idAllocatorLayer))); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const manager = yield* ProviderSessionManagerV2; + const threadId = ThreadId.make(`thread:acp-selection-${scenario}`); + const projection = orchestrator.getThreadProjection(threadId); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const send = (name: string, model: string) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(name), + threadId, + messageId: MessageId.make(name), + text: name, + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + modelSelection: { + instanceId, + model, + ...(scenario === "config-response" + ? { options: [{ id: "mode", value: name === "first" ? "code" : "architect" }] } + : {}), + }, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:acp-selection"), + title: "ACP attribution", + modelSelection: { instanceId, model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const settled = yield* watch( + (event) => event.type === "run.updated" && event.payload.status === "completed", + ); + yield* send("first", "default"); + yield* Fiber.join(settled); + yield* worker.drain(); + const binding = (yield* projection).providerThreads[0]!; + const runtime = Option.getOrThrow(yield* manager.get(binding.providerSessionId!)); + assert.isFalse(yield* runtime.hasBufferedOutputForThread!(binding)); + armed = true; + const failed = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.status === (scenario === "config-response" ? "completed" : "failed"), + ); + yield* send("change", "composer-2"); + yield* Fiber.join(failed); + yield* worker.drain(); + if (scenario !== "before-application") yield* buffer(); + assert.isTrue(yield* runtime.hasBufferedOutputForThread!(binding)); + const evidenceBeforeDrain = yield* runtime.executionSelection!(binding); + if (scenario === "config-response") { + assert.deepEqual(evidenceBeforeDrain, { + instanceId, + model: "composer-2", + options: [{ id: "mode", value: "architect" }], + }); + } else { + assert.isNull(evidenceBeforeDrain); + } + const warnings = logs.filter((message) => + encodeLog(message).includes("acp-selection-step-indeterminate"), + ); + assert.lengthOf(warnings, scenario === "indeterminate" ? 1 : 0); + if (scenario === "indeterminate") { + assert.include(encodeLog(warnings), '"optionId":"model"'); + assert.include(encodeLog(warnings), '"sessionId":"mock-session-1"'); + } + const offered = yield* Queue.take(offers); + const requests = yield* Queue.unbounded(); + const dispatched = yield* Queue.unbounded(); + yield* Layer.build( + workerLive.pipe( + Layer.provide( + Layer.mergeAll( + idAllocatorLayer, + Layer.succeed(ProviderContinuationRequests, { + offer: (request) => Queue.offer(requests, request).pipe(Effect.asVoid), + take: Queue.take(requests), + }), + Layer.mock(ThreadManagementService)({ + getThreadRecords: orchestrator.getThreadRecords, + dispatch: (command) => + orchestrator + .dispatch(command) + .pipe( + Effect.onExit((exit) => + Queue.offer(dispatched, Exit.isSuccess(exit)).pipe(Effect.asVoid), + ), + ), + }), + ), + ), + ), + ); + const beforeDrain = { modelCalls: modelCalls.length, modeCalls, prompts }; + const delivered = yield* watch( + (event) => + event.type === "run.updated" && + ["completed", "failed"].includes(event.payload.status), + ); + yield* Queue.offer(requests, offered); + assert.isTrue( + yield* Queue.take(dispatched), + "owned producer-attributed output must be admitted", + ); + yield* Fiber.join(delivered); + yield* worker.drain(); + assert.deepEqual( + { modelCalls: modelCalls.length, modeCalls, prompts }, + beforeDrain, + "drain must neither configure nor prompt", + ); + assert.deepEqual( + yield* runtime.executionSelection!(binding), + evidenceBeforeDrain, + "drain cannot change configuration evidence", + ); + const current = yield* projection; + const expectedModel = + scenario === "partial-model" || scenario === "config-response" + ? "composer-2" + : "default"; + assert.equal(current.runs.at(-1)?.modelSelection.model, expectedModel); + if (scenario === "config-response") + assert.deepEqual(current.runs.at(-1)?.modelSelection.options, [ + { id: "mode", value: "code" }, + ]); + assert.isTrue( + current.messages.some((message) => + message.text.includes(`Retained ${scenario} output`), + ), + ); + if (scenario === "mixed-prefix") { + assert.isTrue( + yield* runtime.hasBufferedOutputForThread!(binding), + "different producing label remains after prefix drain", + ); + assert.equal( + (yield* runtime.bufferedExecutionSelection!(binding))?.model, + "composer-2", + ); + assert.equal(yield* Queue.size(offers), 1, "prefix completion re-offers its remainder"); + const next = yield* Queue.take(offers); + const completed = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.ordinal === 4 && + event.payload.status === "completed", + ); + yield* Queue.offer(requests, next); + assert.isTrue( + yield* Queue.take(dispatched), + "remainder is re-offered after prefix completion", + ); + yield* Fiber.join(completed); + yield* worker.drain(); + assert.equal((yield* projection).runs.at(-1)?.modelSelection.model, "composer-2"); + assert.deepEqual({ modelCalls: modelCalls.length, modeCalls, prompts }, beforeDrain); + assert.isNull(yield* runtime.executionSelection!(binding)); + } + assert.isFalse(yield* runtime.hasBufferedOutputForThread!(binding)); + assert.equal(prompts, scenario === "config-response" ? 2 : 1, "no prompt is replayed"); + armed = false; + const requestsBeforeRetry = yield* modelRequests(); + const recovered = yield* watch( + (event) => event.type === "run.updated" && event.payload.status === "completed", + ); + yield* send("explicit-retry", "composer-2"); + yield* Fiber.join(recovered); + yield* worker.drain(); + assert.equal(opens, 1, "full reapplication must reuse the native session"); + assert.isAbove(modelCalls.length, beforeDrain.modelCalls); + assert.isTrue(modelCalls.at(-1)?.force); + assert.isAbove( + yield* modelRequests(), + requestsBeforeRetry, + "reapplication must reach native wire even when the runtime cache matches", + ); + assert.deepEqual(yield* runtime.executionSelection!(binding), { + instanceId, + model: "composer-2", + ...(scenario === "config-response" + ? { options: [{ id: "mode", value: "architect" }] } + : {}), + }); + assert.equal(prompts, scenario === "config-response" ? 3 : 2); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: `acp-selection-${scenario}` }, + makeSingleLayer(adapter), + ).pipe(Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false }))), + ), + ); + }), + ), + ); +} diff --git a/apps/server/src/orchestration-v2/SelectionRecoveryClaude.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRecoveryClaude.integration.test.ts new file mode 100644 index 000000000000..66bd9b45427a --- /dev/null +++ b/apps/server/src/orchestration-v2/SelectionRecoveryClaude.integration.test.ts @@ -0,0 +1,401 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + ClaudeSettings, + CommandId, + MessageId, + ProjectId, + ThreadId, + type ModelSelection, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import type * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { + CLAUDE_DEFAULT_INSTANCE_ID, + ClaudeAgentSdkQueryRunnerError, + makeClaudeAdapterV2, +} from "./Adapters/ClaudeAdapterV2.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { layer as idAllocatorLayer, IdAllocatorV2 } from "./IdAllocator.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import { + ProviderContinuationRequests, + type ProviderContinuationRequest, +} from "./ProviderContinuationRequests.ts"; +import { workerLive } from "./ProviderContinuationService.ts"; +import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const NATIVE = "native-selection-recovery"; +const FLUSH = "00000000-0000-4000-8000-000000000099"; +const SETTINGS = Schema.decodeSync(ClaudeSettings)({}); +const A: ModelSelection = { + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-sonnet-4-6", + options: [{ id: "effort", value: "high" }], +}; +const B: ModelSelection = { ...A, options: [{ id: "effort", value: "low" }] }; +const ULTRA: ModelSelection = { ...A, options: [{ id: "effort", value: "ultrathink" }] }; +const frame = (value: unknown) => value as SDKMessage; +const result = (id: number, text: string, wake = false) => + frame({ + type: "result", + subtype: "success", + duration_ms: 10, + duration_api_ms: 10, + is_error: false, + num_turns: 1, + result: text, + stop_reason: "end_turn", + total_cost_usd: 0, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: `00000000-0000-4000-8000-${String(id).padStart(12, "0")}`, + session_id: NATIVE, + ...(wake ? { origin: { kind: "task-notification" } } : {}), + }); +const started = frame({ + type: "system", + subtype: "task_started", + task_id: "build", + description: "build", + task_type: "local_bash", + uuid: "started", + session_id: NATIVE, +}); +const finished = frame({ + type: "system", + subtype: "task_notification", + task_id: "build", + status: "completed", + output_file: "/tmp/build.log", + summary: "Build finished", + uuid: "finished", + session_id: NATIVE, +}); +const assistant = frame({ + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: "The build has finished." }] }, + parent_tool_use_id: null, + uuid: "assistant", + session_id: NATIVE, +}); + +for (const scenario of [ + "revert", + "refused-wake", + "in-flight", + "failed-offer", + "idle-exit", + "notification-only", + "saved-selection", +] as const) { + it.effect(`Claude selection recovery: ${scenario}`, () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`selection-recovery-${scenario}`); + const sdk = yield* Queue.unbounded(); + const flushed = yield* Queue.unbounded(); + const offers = yield* Queue.unbounded(); + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + const streamExit = yield* Deferred.make(); + const threadId = ThreadId.make(`thread:selection-recovery:${scenario}`); + const producingSelection = + scenario === "failed-offer" || scenario === "idle-exit" ? ULTRA : A; + let opens = 0; + let closes = 0; + const prompts: string[] = []; + const adapter = yield* Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: SETTINGS, + environment: {}, + fileSystem, + attachmentsDir: yield* fileSystem.makeTempDirectoryScoped({ + prefix: "selection-recovery-", + }), + path: yield* Path.Path, + idAllocator: yield* IdAllocatorV2, + continuationRequests: { + offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), + }, + queryRunner: { + allocateSessionId: Effect.succeed(NATIVE), + open: () => + Effect.sync(() => { + opens++; + return { + messages: Stream.fromQueue(sdk).pipe( + Stream.tap((message) => + Reflect.get(message, "uuid") === FLUSH + ? Queue.offer(flushed, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Stream.ensuring(Deferred.succeed(streamExit, undefined)), + ), + offer: (message) => + Effect.gen(function* () { + prompts.push(String(message.message.content)); + if ( + prompts.length === 2 && + (scenario === "in-flight" || scenario === "failed-offer") + ) { + yield* Deferred.succeed(reached, undefined); + yield* Deferred.await(release); + if (scenario === "failed-offer") + return yield* new ClaudeAgentSdkQueryRunnerError({ + method: "offer", + cause: new Error("offer rejected"), + }); + } + }), + setModel: () => Effect.die("live query model must remain fixed"), + interrupt: Effect.void, + close: Effect.sync(() => { + closes++; + }).pipe(Effect.andThen(Queue.shutdown(sdk))), + }; + }), + forkSession: () => Effect.die("unused fork"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const manager = yield* ProviderSessionManagerV2; + const projection = orchestrator.getThreadProjection(threadId); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const emit = (...messages: SDKMessage[]) => + Queue.offerAll(sdk, [ + ...messages, + frame({ + type: "system", + subtype: "status", + status: null, + uuid: FLUSH, + session_id: NATIVE, + }), + ]).pipe(Effect.andThen(Queue.take(flushed))); + const send = (name: string, selection = producingSelection) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(name), + threadId, + messageId: MessageId.make(`message:${name}`), + text: name, + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + modelSelection: selection, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:selection-recovery"), + title: "Selection recovery", + modelSelection: producingSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + yield* send("first"); + yield* worker.drain(); + const settled = yield* watch( + (event) => + event.type === "run.updated" && + (event.payload.status === "waiting" || event.payload.status === "completed"), + ); + yield* emit(started, result(2, "Kicked off.")); + yield* Fiber.join(settled); + yield* worker.drain(); + const binding = (yield* projection).providerThreads[0]!; + const runtime = Option.getOrThrow(yield* manager.get(binding.providerSessionId!)); + if (scenario === "revert" || scenario === "refused-wake") { + const failed = yield* watch( + (event) => event.type === "run.updated" && event.payload.status === "failed", + ); + yield* send("change", B); + yield* worker.drain(); + yield* Fiber.join(failed); + assert.deepEqual( + yield* runtime.executionSelection!(binding), + A, + "clean refusal preserves the intact live query selection", + ); + assert.equal(closes, 0); + if (scenario === "revert") { + assert.isTrue(Exit.isSuccess(yield* Effect.exit(send("revert", A)))); + yield* worker.drain(); + assert.lengthOf(prompts, 2); + assert.equal(opens, 1); + return; + } + } + yield* emit(finished); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(binding)); + if (scenario === "notification-only") { + assert.isFalse( + yield* runtime.hasBufferedOutputForThread!(binding), + "opaque notification-only traffic has no native output to protect", + ); + assert.isTrue(Exit.isSuccess(yield* Effect.exit(send("replace", B)))); + yield* worker.drain(); + assert.equal(opens, 2); + assert.equal(closes, 1); + assert.lengthOf(prompts, 2); + return; + } + yield* emit(assistant, result(5, "Wake complete", true)); + const offered = yield* Queue.take(offers); + if (scenario === "idle-exit") { + yield* Queue.end(sdk); + yield* Deferred.await(streamExit); + yield* Effect.yieldNow; + assert.isNull(yield* runtime.liveExecutionSelection?.(binding) ?? Effect.succeed(null)); + assert.deepEqual( + yield* runtime.bufferedExecutionSelection?.(binding) ?? Effect.succeed(null), + producingSelection, + ); + } + if (scenario === "saved-selection") { + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("save"), + threadId, + modelSelection: B, + }); + } + const requests = yield* Queue.unbounded(); + const dispatched = yield* Queue.unbounded(); + yield* Layer.build( + workerLive.pipe( + Layer.provide( + Layer.mergeAll( + idAllocatorLayer, + Layer.succeed(ProviderContinuationRequests, { + offer: (request) => Queue.offer(requests, request).pipe(Effect.asVoid), + take: Queue.take(requests), + }), + Layer.mock(ThreadManagementService)({ + getThreadRecords: orchestrator.getThreadRecords, + dispatch: (command) => + orchestrator + .dispatch(command) + .pipe( + Effect.onExit((exit) => + Queue.offer(dispatched, Exit.isSuccess(exit)).pipe(Effect.asVoid), + ), + ), + }), + ), + ), + ), + ); + const delivered = yield* watch( + (event) => + event.type === "turn-item.updated" && + event.payload.type === "assistant_message" && + event.payload.text === "The build has finished.", + ); + if (scenario === "in-flight" || scenario === "failed-offer") { + yield* send("second"); + const drain = yield* worker.drain().pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(reached); + yield* Queue.offer(requests, offered); + const admitted = yield* Queue.take(dispatched); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(drain); + assert.isTrue(admitted, "a buffered wake queues behind pending acknowledgment"); + if (scenario === "failed-offer") { + // A provider failure holds the queue until the user resumes it. + assert.isTrue((yield* projection).runs.at(-1)?.queueHeld); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make("resume"), + threadId, + }); + } + if (scenario === "in-flight") { + const done = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.ordinal === 2 && + (event.payload.status === "waiting" || event.payload.status === "completed"), + ); + yield* emit(result(8, "Second complete")); + yield* Fiber.join(done); + } + } else { + yield* Queue.offer(requests, offered); + assert.isTrue(yield* Queue.take(dispatched)); + } + yield* worker.drain(); + assert.notEqual((yield* projection).runs.at(-1)?.status, "failed"); + yield* Fiber.join(delivered); + yield* worker.drain(); + const after = yield* projection; + const wakeRun = after.runs.at(-1)!; + assert.deepEqual(wakeRun.modelSelection, producingSelection); + assert.isFalse(yield* runtime.hasBufferedOutputForThread!(binding)); + assert.equal( + opens, + 1, + "draining never replaces the producing query, even after its idle exit", + ); + assert.equal(closes, 0); + assert.lengthOf(prompts, scenario === "in-flight" || scenario === "failed-offer" ? 2 : 1); + if (scenario === "failed-offer") { + assert.equal(after.runs[1]?.status, "failed"); + assert.deepEqual(yield* runtime.executionSelection!(binding), ULTRA); + assert.isTrue(prompts.every((prompt) => prompt.startsWith("Ultrathink:"))); + } + if (scenario === "saved-selection") assert.deepEqual(after.thread.modelSelection, B); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: `selection-recovery-${scenario}` }, + makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + }), + ), + ); +} diff --git a/apps/server/src/orchestration-v2/SelectionRecoveryCodexFailedStart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRecoveryCodexFailedStart.integration.test.ts new file mode 100644 index 000000000000..b4893a8d2d4a --- /dev/null +++ b/apps/server/src/orchestration-v2/SelectionRecoveryCodexFailedStart.integration.test.ts @@ -0,0 +1,373 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CodexSettings, + CommandId, + MessageId, + ProjectId, + ThreadId, + type ModelSelection, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import * as CodexClient from "effect-codex-app-server/client"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stdio from "effect/Stdio"; +import * as Stream from "effect/Stream"; +import { CODEX_DEFAULT_INSTANCE_ID, makeCodexAdapterV2 } from "./Adapters/CodexAdapterV2.ts"; +import { makeReplayServerConfig } from "./Adapters/CodexAdapterV2.testkit.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { layer as idAllocatorLayer, IdAllocatorV2 } from "./IdAllocator.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import { + ProviderContinuationRequests, + type ProviderContinuationRequest, +} from "./ProviderContinuationRequests.ts"; +import { workerLive } from "./ProviderContinuationService.ts"; +import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const A: ModelSelection = { + instanceId: CODEX_DEFAULT_INSTANCE_ID, + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], +}; +const B: ModelSelection = { + ...A, + options: [ + { id: "reasoningEffort", value: "low" }, + { id: "serviceTier", value: "flex" }, + ], +}; +const NATIVE = "native-buffer-codex"; +const settings = Schema.decodeSync(CodexSettings)({}); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeRequest = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + id: Schema.optional(Schema.Number), + method: Schema.String, + params: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + }), + ), +); +const turn = (id: string, status = "inProgress") => ({ + id, + status, + items: [], + itemsView: "notLoaded", + error: null, + startedAt: 1782622440, + completedAt: status === "completed" ? 1782622450 : null, + durationMs: null, +}); +const commandItem = (status: string) => ({ + type: "commandExecution", + id: "command-background", + command: "sleep 60", + cwd: "/workspace", + processId: "4242", + source: "unifiedExecStartup", + status, + commandActions: [{ type: "unknown", command: "sleep 60" }], + aggregatedOutput: "finished", + exitCode: status === "completed" ? 0 : null, + durationMs: null, +}); + +for (const background of [true, false]) + for (const changed of [false, true, "ownership"] as const) { + it.effect(`Codex failed start recovery: background=${background}, changed=${changed}`, () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("codex-buffer-policy"); + const inbound = yield* Queue.unbounded(); + const offered = yield* Queue.unbounded(); + const acceptedStarts: Array> = []; + const opens: Array = []; + const encoder = new TextEncoder(); + const emit = (frame: unknown) => + Queue.offer(inbound, encoder.encode(`${encodeJson(frame)}\n`)).pipe(Effect.asVoid); + const notify = (method: string, params: unknown) => emit({ method, params }); + let remainder = ""; + let failNextStart = false; + const adapter = yield* Effect.gen(function* () { + const client = yield* CodexClient.make( + Stdio.make({ + args: Effect.succeed([]), + stdin: Stream.fromQueue(inbound), + stderr: () => Sink.drain, + stdout: () => + Sink.forEach((chunk) => + Effect.gen(function* () { + remainder += + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk); + const lines = remainder.split("\n"); + remainder = lines.pop()!; + for (const line of lines.filter(Boolean)) { + const request = yield* decodeRequest(line).pipe(Effect.orDie); + if (request.method === "initialized") continue; + let result: unknown; + if (request.method === "initialize") + result = { + userAgent: "test", + codexHome: "/tmp/codex", + platformFamily: "unix", + platformOs: "linux", + }; + else if ( + request.method === "thread/start" || + request.method === "thread/resume" + ) + result = { + thread: { + id: NATIVE, + sessionId: NATIVE, + forkedFromId: null, + preview: "", + projectId: null, + ephemeral: false, + modelProvider: "openai", + createdAt: 1782622440, + updatedAt: 1782622440, + status: { type: "idle" }, + path: `/tmp/${NATIVE}.jsonl`, + cwd, + cliVersion: "0.144.0", + source: "vscode", + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }, + model: A.model, + modelProvider: "openai", + serviceTier: null, + cwd, + instructionSources: [], + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: { type: "dangerFullAccess" }, + reasoningEffort: "high", + }; + else if (request.method === "turn/start" && failNextStart) { + failNextStart = false; + yield* emit({ + id: request.id, + error: { code: -32000, message: "review transient turn/start failure" }, + }); + continue; + } else if (request.method === "turn/start") { + acceptedStarts.push(request.params ?? {}); + result = { turn: turn(`turn-${acceptedStarts.length}`) }; + } else { + yield* emit({ + id: request.id, + error: { + code: -32601, + message: `Unexpected Codex request: ${request.method}`, + }, + }); + continue; + } + yield* emit({ id: request.id, result }); + } + }), + ), + }), + ); + return makeCodexAdapterV2({ + instanceId: CODEX_DEFAULT_INSTANCE_ID, + settings, + environment: {}, + fileSystem: yield* FileSystem.FileSystem, + idAllocator: yield* IdAllocatorV2, + serverConfig: yield* makeReplayServerConfig("buffer-policy-codex"), + clientFactory: { + open: (input) => + Effect.sync(() => { + opens.push(input.providerSessionId); + return client; + }), + }, + continuationRequests: { + offer: (request) => Queue.offer(offered, request).pipe(Effect.asVoid), + }, + }); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const manager = yield* ProviderSessionManagerV2; + const threadId = ThreadId.make(`thread:review-codex-failed:${background}`); + const projection = orchestrator.getThreadProjection(threadId); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const send = (name: string, modelSelection?: ModelSelection) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(name), + threadId, + messageId: MessageId.make(`message:${name}`), + text: name, + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + ...(modelSelection ? { modelSelection } : {}), + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:codex-buffer"), + title: "Codex buffer", + modelSelection: A, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const started = yield* watch( + (event) => + event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* send("first"); + yield* worker.drain(); + yield* Fiber.join(started); + const settled = yield* watch( + (event) => event.type === "run.updated" && event.payload.status === "waiting", + ); + yield* notify("item/started", { + threadId: NATIVE, + turnId: "turn-1", + item: commandItem("inProgress"), + startedAtMs: 1782622440000, + }); + yield* notify("turn/completed", { + threadId: NATIVE, + turn: turn("turn-1", "completed"), + }); + yield* Fiber.join(settled); + yield* worker.drain(); + const binding = (yield* projection).providerThreads[0]!; + const runtime = Option.getOrThrow(yield* manager.get(binding.providerSessionId!)); + if (changed === "ownership") { + const staleWake = yield* Effect.exit( + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("stale-buffered-wake"), + threadId, + messageId: MessageId.make("stale-buffered-wake"), + text: "Stale buffered signal", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "agent", + creationSource: "provider", + }), + ); + assert.isTrue( + Exit.isFailure(staleWake), + "matching selection without owned output cannot admit a buffered wake", + ); + assert.lengthOf((yield* projection).runs, 1); + return; + } + const log = (label: string, value: unknown) => Effect.logDebug(label, value); + if (!background) { + yield* notify("item/completed", { + threadId: NATIVE, + turnId: "turn-1", + item: commandItem("completed"), + completedAtMs: 1782622450000, + }); + yield* Queue.take(offered); + } + yield* log( + "pendingForThread", + yield* runtime.hasPendingBackgroundWorkForThread!(binding), + ); + yield* log("executionBeforeFailure", yield* runtime.executionSelection!(binding)); + failNextStart = true; + const failed = yield* watch( + (event) => event.type === "run.updated" && event.payload.status === "failed", + ); + const second = yield* Effect.exit(send("second", changed ? B : A)); + yield* log("second.exit", second._tag); + yield* worker.drain(); + yield* Fiber.join(failed); + yield* log("afterFailure.execution", yield* runtime.executionSelection!(binding)); + yield* log( + "afterFailure.runs", + (yield* projection).runs.map((run) => [run.ordinal, run.status]), + ); + if (changed) assert.isNull(yield* runtime.executionSelection!(binding)); + const third = yield* Effect.exit(send("third", changed ? B : A)); + yield* log("third.exit", third._tag === "Failure" ? String(third.cause) : "Success"); + yield* worker.drain(); + const after = yield* projection; + yield* log( + "third.runs", + after.runs.map((run) => [run.ordinal, run.status, run.providerThreadId]), + ); + yield* log("opens", opens); + yield* log( + "providerSessions", + after.providerSessions.map((session) => [session.id, session.status]), + ); + yield* log("acceptedStarts", acceptedStarts.length); + assert.equal( + third._tag, + "Success", + "same full selection retry after a transient Codex turn/start failure is non-destructive", + ); + assert.lengthOf( + opens, + 1, + "no extra app-server process for a non-destructive Codex retry", + ); + assert.deepEqual(yield* runtime.executionSelection!(binding), changed ? B : A); + assert.isTrue(yield* runtime.reappliesFullSelection!(B)); + for (const omitted of ["reasoningEffort", "serviceTier"]) { + assert.isFalse( + yield* runtime.reappliesFullSelection!({ + ...B, + options: B.options!.filter((option) => option.id !== omitted), + }), + ); + } + if (changed) { + assert.equal(acceptedStarts.at(-1)?.effort, "low"); + assert.equal(acceptedStarts.at(-1)?.serviceTier, "flex"); + } + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: "codex-buffer-policy" }, + makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + }), + ), + ); + } diff --git a/apps/server/src/orchestration-v2/SelectionRecoveryOpenCode.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRecoveryOpenCode.integration.test.ts new file mode 100644 index 000000000000..3ccde30733fc --- /dev/null +++ b/apps/server/src/orchestration-v2/SelectionRecoveryOpenCode.integration.test.ts @@ -0,0 +1,159 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CommandId, + MessageId, + OpenCodeSettings, + ProjectId, + ProviderInstanceId, + ThreadId, + type ModelSelection, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import type { ServerConfig } from "../config.ts"; +import type { OpenCodeRuntimeShape } from "../provider/opencodeRuntime.ts"; +import { makeOpenCodeAdapterV2 } from "./Adapters/OpenCodeAdapterV2.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { layer as idAllocatorLayer, IdAllocatorV2 } from "./IdAllocator.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const settings = Schema.decodeSync(OpenCodeSettings)({ serverUrl: "http://test.invalid" }); +for (const missing of [null, "agent", "variant"] as const) { + it.effect(`OpenCode real-adapter selection recovery with omitted=${missing}`, () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("opencode-selection-recovery"); + const instanceId = ProviderInstanceId.make("opencode-selection"); + const selection: ModelSelection = { + instanceId, + model: "anthropic/claude-sonnet", + options: [ + { id: "agent", value: "build" }, + { id: "variant", value: "high" }, + ], + }; + let opens = 0; + let creates = 0; + const prompts: Array> = []; + const adapter = yield* Effect.gen(function* () { + return makeOpenCodeAdapterV2({ + instanceId, + settings, + environment: {}, + runtime: { + connectToOpenCodeServer: () => + Effect.sync(() => { + opens++; + return { url: "http://test.invalid", external: true }; + }), + createOpenCodeSdkClient: () => ({ + event: { + subscribe: async () => ({ + stream: { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + }), + }, + }), + }, + session: { + create: async () => { + creates++; + return { data: { id: `root-${creates}`, time: { created: 1, updated: 1 } } }; + }, + get: async () => ({ data: { id: "root-1", time: { created: 1, updated: 1 } } }), + promptAsync: async (input: Record) => { + prompts.push(input); + if (prompts.length === 1) throw new Error("fixture rejected admission"); + return { data: true }; + }, + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + messages: async () => ({ data: [] }), + status: async () => ({ data: {} }), + }, + }), + } as unknown as OpenCodeRuntimeShape, + idAllocator: yield* IdAllocatorV2, + serverConfig: { cwd, attachmentsDir: "/tmp/attachments" } as ServerConfig["Service"], + }); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const manager = yield* ProviderSessionManagerV2; + const threadId = ThreadId.make("thread:opencode-selection"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:selection"), + title: "selection", + modelSelection: selection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const send = (name: string, modelSelection: ModelSelection) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(name), + threadId, + messageId: MessageId.make(name), + text: name, + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + modelSelection, + }); + yield* send("first", selection); + yield* worker.drain(); + const failed = yield* orchestrator.getThreadProjection(threadId); + assert.equal(failed.runs.at(-1)?.status, "failed"); + const binding = failed.providerThreads[0]!; + const runtime = Option.getOrThrow(yield* manager.get(binding.providerSessionId!)); + assert.isNull(yield* runtime.executionSelection!(binding)); + const target = { + ...selection, + options: selection.options!.filter((option) => option.id !== missing), + }; + assert.equal(yield* runtime.reappliesFullSelection!(target), missing === null); + yield* send("explicit-retry", target); + yield* worker.drain(); + const current = yield* orchestrator.getThreadProjection(threadId); + assert.equal(current.runs.at(-1)?.status, "running"); + assert.equal(opens, missing === null ? 1 : 2); + assert.lengthOf(prompts, 2, "only the explicit retry is sent"); + const currentBinding = current.providerThreads.find( + (thread) => thread.id === current.thread.activeProviderThreadId, + )!; + const currentRuntime = Option.getOrThrow( + yield* manager.get(currentBinding.providerSessionId!), + ); + assert.deepEqual(yield* currentRuntime.executionSelection!(currentBinding), target); + assert.equal(prompts[1]?.agent, missing === "agent" ? undefined : "build"); + assert.equal(prompts[1]?.variant, missing === "variant" ? undefined : "high"); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: "opencode-selection" }, + makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + }), + ), + ); +} diff --git a/apps/server/src/orchestration-v2/SelectionRecoveryOpenCodeStopAdmission.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRecoveryOpenCodeStopAdmission.integration.test.ts new file mode 100644 index 000000000000..b9d2fe5cd48d --- /dev/null +++ b/apps/server/src/orchestration-v2/SelectionRecoveryOpenCodeStopAdmission.integration.test.ts @@ -0,0 +1,222 @@ +// Review probe: Stop winning OpenCode prompt admission, driven through the real +// orchestrator, manager, RunExecutionService and effect worker (fake SDK client). +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CommandId, + MessageId, + OpenCodeSettings, + ProjectId, + ProviderInstanceId, + ThreadId, + type ModelSelection, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import type { ServerConfig } from "../config.ts"; +import type { OpenCodeRuntimeShape } from "../provider/opencodeRuntime.ts"; +import { makeOpenCodeAdapterV2 } from "./Adapters/OpenCodeAdapterV2.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { layer as idAllocatorLayer, IdAllocatorV2 } from "./IdAllocator.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const SETTINGS = Schema.decodeSync(OpenCodeSettings)({ serverUrl: "http://test.invalid" }); +const instanceId = ProviderInstanceId.make("opencode-review"); +const selection: ModelSelection = { instanceId, model: "anthropic/claude-sonnet", options: [] }; + +function gate() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +const unexpected: Array = []; +function tolerant(path: string, known: object): unknown { + return new Proxy(known, { + get(target, key) { + if (typeof key !== "string") return Reflect.get(target, key); + if (key in target) return Reflect.get(target, key); + if (key === "then") return undefined; + return tolerant( + `${path}.${key}`, + Object.assign(async () => { + unexpected.push(`${path}.${key}`); + return { data: undefined }; + }, {}), + ); + }, + }); +} + +for (const reply of ["success", "failure"] as const) { + it.effect(`review probe OpenCode Stop during prompt admission (${reply}) through pipeline`, () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`review-opencode-${reply}`); + const called = gate(); + const events: Array<(value: IteratorResult) => void> = []; + const eventStream = { + [Symbol.asyncIterator]() { + return { + next: () => new Promise>((resolve) => events.push(resolve)), + }; + }, + }; + const client = tolerant("client", { + event: tolerant("event", { subscribe: async () => ({ stream: eventStream }) }), + session: tolerant("session", { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async (_input: unknown, options: { signal: AbortSignal }) => { + called.resolve(); + return new Promise((resolve, reject) => { + options.signal.addEventListener( + "abort", + () => + reply === "success" ? resolve({ data: true }) : reject(new Error("cancelled")), + { once: true }, + ); + }); + }, + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + messages: async () => ({ data: [] }), + status: async () => ({ data: {} }), + }), + }); + const adapter = yield* Effect.gen(function* () { + return makeOpenCodeAdapterV2({ + instanceId, + settings: SETTINGS, + environment: {}, + runtime: { + connectToOpenCodeServer: () => + Effect.succeed({ url: "http://test.invalid", external: true }), + createOpenCodeSdkClient: () => client, + } as unknown as OpenCodeRuntimeShape, + idAllocator: yield* IdAllocatorV2, + serverConfig: { cwd, attachmentsDir: "/tmp/attachments" } as ServerConfig["Service"], + }); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const threadId = ThreadId.make(`thread:review-opencode:${reply}`); + const projection = orchestrator.getThreadProjection(threadId); + const log = (label: string, value: unknown) => Effect.logDebug(label, value); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:review-opencode"), + title: "OpenCode", + modelSelection: selection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const terminal = yield* watch( + (event) => + event.type === "run.updated" && + ["failed", "interrupted", "completed", "waiting"].includes(event.payload.status), + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("first"), + threadId, + messageId: MessageId.make("message:first"), + text: "first", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const firstDrain = yield* worker + .drain() + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => called.promise); + const beforeStop = yield* projection; + yield* log( + "beforeStop.runs", + beforeStop.runs.map((run) => [run.ordinal, run.status]), + ); + const stop = yield* Effect.exit( + orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("stop"), + threadId, + runId: beforeStop.runs[0]!.id, + }), + ); + yield* log("stop.exit", stop._tag === "Failure" ? String(stop.cause) : "Success"); + const secondDrain = yield* worker + .drain() + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + const settled = yield* Fiber.join(terminal); + yield* Fiber.join(firstDrain); + yield* Fiber.join(secondDrain); + yield* worker.drain().pipe(Effect.exit); + const after = yield* projection; + yield* log( + "firstTerminal", + settled._tag === "Some" && settled.value.type === "run.updated" + ? settled.value.payload.status + : null, + ); + yield* log( + "after.runs", + after.runs.map((run) => [run.ordinal, run.status]), + ); + yield* log( + "after.errorItems", + after.turnItems + .filter((item) => item.type === "error") + .map((item) => [item.title, item.failure?.message]), + ); + yield* log( + "after.interruptItems", + after.turnItems + .filter((item) => item.type === "run_interrupt_result") + .map((item) => [item.title, item.status]), + ); + yield* log("unexpectedClientCalls", [...new Set(unexpected)]); + assert.equal( + after.runs[0]?.status, + "interrupted", + "Stop during admission settles the run as interrupted", + ); + assert.lengthOf( + after.turnItems.filter((item) => item.type === "error"), + 0, + "no provider error item for an explicit Stop", + ); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: `review-opencode-${reply}` }, + makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + }), + ), + ); +} diff --git a/apps/server/src/orchestration-v2/SelectionSettledAcp.integration.test.ts b/apps/server/src/orchestration-v2/SelectionSettledAcp.integration.test.ts new file mode 100644 index 000000000000..d8fd33972113 --- /dev/null +++ b/apps/server/src/orchestration-v2/SelectionSettledAcp.integration.test.ts @@ -0,0 +1,563 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CommandId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderTurnId, + ThreadId, + type ModelSelection, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as AcpErrors from "effect-acp/errors"; +import * as AcpSessionRuntime from "../provider/acp/AcpSessionRuntime.ts"; +import { makeAcpAdapterV2, AcpProviderCapabilitiesV2 } from "./Adapters/AcpAdapterV2.ts"; +import { makeReplayServerConfig } from "./Adapters/CodexAdapterV2.testkit.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { layer as idAllocatorLayer, IdAllocatorV2 } from "./IdAllocator.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import { type ProviderContinuationRequest } from "./ProviderContinuationRequests.ts"; +import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +type Runtime = AcpSessionRuntime.AcpSessionRuntime["Service"]; + +const harness = (input: { + readonly name: string; + readonly terminate: boolean; + readonly rejectModeWhenArmed: boolean; + readonly rejectConfig?: boolean; + readonly holdSecondPrompt?: boolean; +}) => + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(input.name); + const instanceId = ProviderInstanceId.make("acp-selection-settled"); + const driver = ProviderDriverKind.make("acp-selection-settled"); + const offers = yield* Queue.unbounded(); + const stopped = yield* Deferred.make(); + const stopEntered = yield* Deferred.make(); + const promptGate = { + reached: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + const state = { + update: undefined as Parameters[0] | undefined, + armed: false, + opens: 0, + prompts: 0, + terminations: 0, + modelCalls: [] as Array<{ model: string; force: boolean | undefined }>, + configCalls: [] as Array<{ id: string; value: unknown; force: boolean | undefined }>, + }; + const adapter = yield* Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return makeAcpAdapterV2({ + instanceId, + crypto: yield* Crypto.Crypto, + fileSystem: yield* FileSystem.FileSystem, + idAllocator: yield* IdAllocatorV2, + selfInvocation: yield* resolveSelfInvocation(), + serverConfig: yield* makeReplayServerConfig(input.name), + continuationRequests: { + offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), + }, + flavor: { + driver, + capabilities: AcpProviderCapabilitiesV2, + deferFinalizeForBackgroundWork: true, + enablePostSettleContinuation: true, + ...(input.terminate ? { terminateRuntimeProcessGroupOnInterrupt: true } : {}), + sessionModeForPolicy: () => (input.rejectConfig ? undefined : "code"), + makeRuntime: (runtimeInput) => + Effect.gen(function* () { + state.opens++; + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + ...runtimeInput, + spawn: { + command: process.execPath, + args: [new URL("../../scripts/acp-mock-agent.ts", import.meta.url).pathname], + cwd: runtimeInput.cwd, + env: { + T3_ACP_SESSION_LIFECYCLE: "1", + ...(input.rejectConfig ? { T3_ACP_FAIL_SET_CONFIG_OPTION: "1" } : {}), + }, + }, + authMethodId: "test", + ...(input.terminate ? { ownDetachedProcessGroup: true } : {}), + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ), + ), + ); + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime.pipe( + Effect.provide(context), + ); + return { + ...runtime, + handleSessionUpdate: (handler) => + Effect.sync(() => { + state.update = handler; + }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), + prompt: (promptInput) => + Effect.gen(function* () { + state.prompts++; + if (input.holdSecondPrompt && state.prompts === 2) { + yield* Deferred.succeed(promptGate.reached, undefined); + yield* Deferred.await(promptGate.release); + } + return yield* runtime.prompt(promptInput); + }), + setModel: (model: string, force?: boolean) => + Effect.sync(() => { + state.modelCalls.push({ model, force }); + }).pipe(Effect.andThen(runtime.setModel(model, force))), + setConfigOption: (id: string, value: string | boolean, force?: boolean) => + Effect.sync(() => { + state.configCalls.push({ id, value, force }); + }).pipe(Effect.andThen(runtime.setConfigOption(id, value, force))), + setMode: (mode: string, force?: boolean) => + state.armed && input.rejectModeWhenArmed + ? Effect.fail( + new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: "selection-settled mode rejected", + operation: "receive-response", + }), + ) + : runtime.setMode(mode, force), + ...(input.terminate && runtime.terminateProcessGroup !== undefined + ? { + terminateProcessGroup: Effect.sync(() => { + state.terminations++; + }).pipe(Effect.andThen(runtime.terminateProcessGroup)), + } + : {}), + }; + }), + }, + }); + }).pipe(Effect.provide(Layer.merge(NodeServices.layer, idAllocatorLayer))); + const observedAdapter = { + ...adapter, + openSession: (input: Parameters[0]) => + adapter.openSession(input).pipe( + Effect.map((runtime) => ({ + ...runtime, + interruptTurn: (input: Parameters[0]) => + Deferred.succeed(stopEntered, undefined).pipe( + Effect.andThen(runtime.interruptTurn(input)), + Effect.ensuring(Deferred.succeed(stopped, undefined)), + ), + })), + ), + }; + return { + adapter: observedAdapter, + instanceId, + offers, + state, + cwd, + stopped, + stopEntered, + promptGate, + }; + }); + +const buffer = ( + state: { update: Parameters[0] | undefined }, + text: string, +) => + state.update!({ + sessionId: "mock-session-1", + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }, + }); + +for (const terminate of [false, true]) { + it.live( + `selection-settled ACP Stop on failed run with retained output: terminate=${terminate}`, + () => + Effect.scoped( + Effect.gen(function* () { + const name = `selection-settled-acp-stop-${terminate}`; + const h = yield* harness({ name, terminate, rejectModeWhenArmed: true }); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const manager = yield* ProviderSessionManagerV2; + const threadId = ThreadId.make(`thread:${name}`); + const projection = orchestrator.getThreadProjection(threadId); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const send = (msg: string, model: string) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(msg), + threadId, + messageId: MessageId.make(msg), + text: msg, + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + modelSelection: { instanceId: h.instanceId, model }, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:selection-settled-acp-stop"), + title: "ACP stop", + modelSelection: { instanceId: h.instanceId, model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: h.cwd, + createdBy: "user", + creationSource: "web", + }); + const settled = yield* watch( + (event) => event.type === "run.updated" && event.payload.status === "completed", + ); + yield* send("first", "default"); + yield* Fiber.join(settled); + yield* worker.drain(); + const binding = (yield* projection).providerThreads[0]!; + const runtime = Option.getOrThrow(yield* manager.get(binding.providerSessionId!)); + h.state.armed = true; + const failed = yield* watch( + (event) => event.type === "run.updated" && event.payload.status === "failed", + ); + yield* send("change", "composer-2"); + yield* Fiber.join(failed); + yield* worker.drain(); + yield* buffer(h.state, "Retained stop output"); + const before = yield* runtime.hasBufferedOutputForThread!(binding); + assert.isTrue(before); + const latest = (yield* projection).runs.at(-1)!; + const stopExit = yield* Effect.exit( + orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("explicit-stop"), + threadId, + runId: latest.id, + }), + ); + yield* worker.drain(); + yield* Deferred.await(h.stopped); + const resident = yield* manager.get(binding.providerSessionId!); + const after = Option.isSome(resident) + ? yield* resident.value.hasBufferedOutputForThread!(binding) + : false; + assert.equal(stopExit._tag, "Success"); + assert.equal(h.state.terminations, terminate ? 1 : 0); + assert.isFalse(after, "explicit Stop discards retained ACP output"); + if (!terminate) { + const request = yield* Queue.take(h.offers); + let dispatched = false; + const stale = yield* request.dispatchIfCurrent!( + Effect.sync(() => { + dispatched = true; + }), + ); + assert.isTrue(Option.isNone(stale)); + assert.isFalse(dispatched, "Stop invalidates its queued continuation"); + yield* buffer(h.state, "Late output after Stop"); + assert.isFalse(yield* runtime.hasBufferedOutputForThread!(binding)); + assert.equal( + yield* Queue.size(h.offers), + 0, + "stopped frames cannot re-offer a continuation", + ); + } + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry({ name }, makeSingleLayer(h.adapter)), + ), + ); + }), + ), + ); +} + +const unexposedCases: ReadonlyArray<{ + readonly label: string; + readonly model: string; + readonly options: NonNullable; + readonly rejectConfig?: boolean; +}> = [ + { + label: "default-agent-rejected-option", + model: "default", + options: [{ id: "mode", value: "code" }], + rejectConfig: true, + }, + { + label: "default-unexposed-effort", + model: "default", + options: [{ id: "effort", value: "high" }], + }, + { + label: "concrete-unexposed-effort", + model: "composer-2", + options: [{ id: "effort", value: "high" }], + }, + { + label: "default-unadvertised-mode", + model: "default", + options: [{ id: "mode", value: "no-such-mode" }], + }, +]; + +for (const scenario of unexposedCases) { + it.live( + `selection-settled ACP unchanged selection with a skipped option: ${scenario.label}`, + () => + Effect.scoped( + Effect.gen(function* () { + const name = `selection-settled-acp-skip-${scenario.label}`; + const h = yield* harness({ + name, + terminate: false, + rejectModeWhenArmed: false, + rejectConfig: scenario.rejectConfig ?? false, + }); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const manager = yield* ProviderSessionManagerV2; + const threadId = ThreadId.make(`thread:${name}`); + const projection = orchestrator.getThreadProjection(threadId); + const selection: ModelSelection = { + instanceId: h.instanceId, + model: scenario.model, + options: scenario.options, + }; + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const sendAndSettle = (msg: string) => + Effect.gen(function* () { + const settled = yield* watch( + (event) => + event.type === "run.updated" && + (event.payload.status === "completed" || event.payload.status === "failed"), + ); + const exit = yield* Effect.exit( + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(msg), + threadId, + messageId: MessageId.make(msg), + text: msg, + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + modelSelection: selection, + }), + ); + if (exit._tag === "Success") yield* Fiber.join(settled); + yield* worker.drain(); + return exit._tag === "Success" ? "Success" : String(exit.cause); + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:selection-settled-acp-skip"), + title: "ACP skipped option", + modelSelection: selection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: h.cwd, + createdBy: "user", + creationSource: "web", + }); + const first = yield* sendAndSettle("first"); + const binding = (yield* projection).providerThreads[0]!; + const runtime = Option.getOrThrow(yield* manager.get(binding.providerSessionId!)); + const evidenceAfterFirst = yield* runtime.executionSelection!(binding); + const callsAfterFirst = { + model: h.state.modelCalls.length, + config: h.state.configCalls.length, + }; + const second = yield* sendAndSettle("second"); + const third = yield* sendAndSettle("third"); + assert.equal(second, "Success"); + assert.equal(third, "Success"); + assert.equal( + h.state.opens, + 1, + "an unchanged selection must not replace the ACP process", + ); + assert.equal(first, "Success"); + assert.equal(h.state.prompts, 3); + assert.deepEqual(evidenceAfterFirst, selection); + assert.deepEqual(yield* runtime.executionSelection!(binding), selection); + assert.equal(h.state.modelCalls.length, callsAfterFirst.model); + assert.equal(h.state.configCalls.length, callsAfterFirst.config); + assert.isFalse(h.state.modelCalls.some((call) => call.force === true)); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry({ name }, makeSingleLayer(h.adapter)), + ), + ); + }), + ), + ); +} + +for (const scenario of ["stale-stop-soft", "stale-stop-hard", "dispatch-racing-stop"] as const) { + it.live(`selection-settled ACP continuation ownership: ${scenario}`, () => + Effect.scoped( + Effect.gen(function* () { + const name = `selection-settled-${scenario}`; + const staleStop = scenario !== "dispatch-racing-stop"; + const h = yield* harness({ + name, + terminate: scenario === "stale-stop-hard", + rejectModeWhenArmed: false, + holdSecondPrompt: staleStop, + }); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const manager = yield* ProviderSessionManagerV2; + const threadId = ThreadId.make(`thread:${name}`); + const projection = orchestrator.getThreadProjection(threadId); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const send = (msg: string) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(msg), + threadId, + messageId: MessageId.make(msg), + text: msg, + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + modelSelection: { instanceId: h.instanceId, model: "default" }, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:selection-stop-race"), + title: "ACP Stop ownership", + modelSelection: { instanceId: h.instanceId, model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: h.cwd, + createdBy: "user", + creationSource: "web", + }); + const settled = yield* watch( + (event) => event.type === "run.updated" && event.payload.status === "completed", + ); + yield* send("first"); + yield* Fiber.join(settled); + yield* worker.drain(); + const binding = (yield* projection).providerThreads[0]!; + const runtime = Option.getOrThrow(yield* manager.get(binding.providerSessionId!)); + yield* buffer(h.state, "Retained output before Stop"); + assert.isTrue(yield* runtime.hasBufferedOutputForThread!(binding)); + const stop = runtime.interruptTurn({ + providerThread: binding, + providerTurnId: ProviderTurnId.make("stale-turn"), + requestRuntimeRestart: true, + }); + if (staleStop) { + const dispatch = yield* send("second").pipe( + Effect.forkChild({ startImmediately: true }), + ); + const drain = yield* worker.drain().pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(h.promptGate.reached); + const before = yield* runtime.hasBufferedOutputForThread!(binding); + const stopped = yield* Effect.exit(stop); + const after = yield* runtime.hasBufferedOutputForThread!(binding); + yield* Deferred.succeed(h.promptGate.release, undefined); + yield* Fiber.join(dispatch); + yield* Fiber.join(drain); + yield* worker.drain(); + assert.equal(stopped._tag, "Success"); + assert.isTrue(before); + assert.isTrue( + after, + "a stale Stop cannot discard output while a different turn is active", + ); + assert.equal(h.state.terminations, 0); + return; + } + const request = yield* Queue.take(h.offers); + const dispatchEntered = yield* Deferred.make(); + const releaseDispatch = yield* Deferred.make(); + const dispatch = yield* request.dispatchIfCurrent!( + Effect.gen(function* () { + yield* Deferred.succeed(dispatchEntered, undefined); + yield* Deferred.await(releaseDispatch); + return yield* runtime.hasBufferedOutputForThread!(binding); + }), + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(dispatchEntered); + const stopFiber = yield* stop.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(h.stopEntered); + yield* Effect.yieldNow; + const stopFinishedDuringDispatch = yield* Deferred.isDone(h.stopped); + yield* Deferred.succeed(releaseDispatch, undefined); + const dispatched = yield* Fiber.join(dispatch); + yield* Fiber.join(stopFiber); + assert.isFalse( + stopFinishedDuringDispatch, + "Stop waits for the admitted dispatch before discarding", + ); + assert.deepEqual( + dispatched, + Option.some(true), + "an admitted continuation never sees an emptied buffer during dispatch", + ); + assert.isFalse(yield* runtime.hasBufferedOutputForThread!(binding)); + assert.isTrue(Option.isNone(yield* request.dispatchIfCurrent!(Effect.succeed("stale")))); + yield* buffer(h.state, "Late frame after racing Stop"); + assert.isFalse(yield* runtime.hasBufferedOutputForThread!(binding)); + assert.equal(yield* Queue.size(h.offers), 0); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry({ name }, makeSingleLayer(h.adapter)), + ), + ); + }), + ), + ); +} diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index c71f84709b27..674217b91439 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2428,7 +2428,7 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { orchestrator.dispatch({ type: "message.dispatch", createdBy: automatic ? "agent" : "user", - creationSource: automatic ? "provider" : "web", + creationSource: automatic ? "server" : "web", ...(automatic ? { notification: { @@ -2637,7 +2637,7 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { yield* orchestrator.dispatch({ type: "message.dispatch", createdBy: automatic ? "agent" : "user", - creationSource: automatic ? "provider" : "web", + creationSource: automatic ? "server" : "web", ...(automatic ? { notification: { diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index e3381514fb9c..4164c690dedf 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -32,6 +32,12 @@ export type OrchestratorV2ScenarioStep = readonly await?: boolean; readonly key?: string; } + | { + /** Dispatches a command that must be refused; its error is kept under `key`. */ + readonly type: "dispatch_refused"; + readonly command: OrchestrationV2Command; + readonly key: string; + } | { readonly type: "advance_clock"; readonly duration: Duration.Input; @@ -85,6 +91,11 @@ export type OrchestratorV2ScenarioStep = readonly type: "release_replay_gate"; readonly label: string; } + | { + /** Waits until the replay holds the entry labelled `label`, and keeps holding it. */ + readonly type: "await_replay_gate"; + readonly label: string; + } | { readonly type: "capture_shell_snapshot"; readonly key: string; @@ -112,6 +123,8 @@ export interface OrchestratorV2ScenarioResult { readonly projections: ReadonlyMap; readonly shellSnapshot: OrchestrationV2ThreadShellSnapshot; readonly capturedShellSnapshots: ReadonlyMap; + /** The errors of `dispatch_refused` steps, by key. */ + readonly refusedDispatches: ReadonlyMap; } export class OrchestratorV2ScenarioStepError extends Schema.TaggedError()( @@ -272,6 +285,7 @@ export function runOrchestratorV2Scenario( Fiber.Fiber, Orchestrator.OrchestratorV2Error> >(); const capturedShellSnapshots = new Map(); + const refusedDispatches = new Map(); let anonymousBackgroundDispatchIndex = 0; const awaitDispatch = (key: string) => @@ -576,17 +590,23 @@ export function runOrchestratorV2Scenario( } }); - const releaseReplayGate = Effect.fn("scenario.releaseReplayGate")(function* (label: string) { + const awaitReplayGate = Effect.fn("scenario.awaitReplayGate")(function* ( + label: string, + step: "await_replay_gate" | "release_replay_gate", + ) { const gate = options.replayGate; const reached = gate === undefined ? false : yield* Effect.promise(() => gate.waitForReached(label)); if (!reached) { return yield* new OrchestratorV2ScenarioStepError({ scenario: scenario.name, - step: `release_replay_gate:${label}:not_configured`, + step: `${step}:${label}:not_configured`, }); } - gate?.release(label); + }); + const releaseReplayGate = Effect.fn("scenario.releaseReplayGate")(function* (label: string) { + yield* awaitReplayGate(label, "release_replay_gate"); + options.replayGate?.release(label); }); for (const step of scenarioSteps(scenario)) { @@ -609,6 +629,17 @@ export function runOrchestratorV2Scenario( ); break; } + case "dispatch_refused": { + const result = yield* Effect.result(orchestrator.dispatch(step.command)); + if (result._tag === "Success") { + return yield* new OrchestratorV2ScenarioStepError({ + scenario: scenario.name, + step: `dispatch_refused:${step.key}:accepted`, + }); + } + refusedDispatches.set(step.key, result.failure); + break; + } case "advance_clock": yield* TestClock.adjust(step.duration); break; @@ -641,6 +672,9 @@ export function runOrchestratorV2Scenario( case "release_replay_gate": yield* releaseReplayGate(step.label); break; + case "await_replay_gate": + yield* awaitReplayGate(step.label, "await_replay_gate"); + break; case "capture_shell_snapshot": capturedShellSnapshots.set(step.key, yield* orchestrator.getShellSnapshot()); break; @@ -695,6 +729,7 @@ export function runOrchestratorV2Scenario( projections, shellSnapshot, capturedShellSnapshots, + refusedDispatches, }; }), ).pipe( diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 2fa1345528f9..caeab811ace4 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -263,7 +263,10 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( readonly continueThreadsAfterServerUpdate?: boolean; } = {}, ): Layer.Layer< - Orchestrator.OrchestratorV2 | EffectWorker.OrchestrationEffectWorkerV2 | EventSink.EventSinkV2, + | Orchestrator.OrchestratorV2 + | EffectWorker.OrchestrationEffectWorkerV2 + | EventSink.EventSinkV2 + | ProviderSessionManager.ProviderSessionManagerV2, Error | MigrationError | PlatformError.PlatformError | SqlError > { const serverConfigLayer = Layer.effect( @@ -459,6 +462,7 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( effectWorkerProvided, eventSinkProvided, continuationWorkerProvided, + providerSessionManagerProvided, ).pipe(Layer.provide(worktreeRepairDependenciesTestLayer), Layer.provide(NodeServices.layer)); // Build the daemon from the exact worker instance exposed alongside the diff --git a/apps/server/src/orchestration-v2/testkit/RollbackAfterRestartAdmission.integration.test.ts b/apps/server/src/orchestration-v2/testkit/RollbackAfterRestartAdmission.integration.test.ts new file mode 100644 index 000000000000..30a729caa2df --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/RollbackAfterRestartAdmission.integration.test.ts @@ -0,0 +1,137 @@ +/** + * A rollback after the provider session's idle release opens a fresh session + * and loads the native thread there before any turn has run on it. The next + * message must admit that binding as a fresh attachment rather than as one + * whose selection became uncertain: replacing it would detach the native + * thread the rollback loaded, mid-rollback or just after it. + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import type { OrchestrationV2DomainEvent } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; +import * as IdAllocator from "../IdAllocator.ts"; +import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; +import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; +import { materializeFixtureInput, type OrchestratorFixtureInput } from "./fixtures/shared.ts"; +import type { OrchestratorV2ScenarioStep } from "./OrchestratorScenario.ts"; +import { runOrchestratorV2ProviderReplayScenario } from "./ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./ReplayFixtureWorkspace.ts"; +import { + materializeReplayTranscriptRuntimeInstructions, + materializeReplayTranscriptWorkspace, + readProviderReplayTranscript, +} from "./ReplayTranscriptNdjson.ts"; + +const FIXTURE = "thread_rollback_after_restart"; +const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find((candidate) => candidate.name === FIXTURE); +const codex = fixture?.providers.find((provider) => provider.driver === "codex"); + +/** The recorded rollback-after-restart run, with its steps reordered by `adjust`. */ +const runRollbackAfterRestart = (input: { + readonly buildInput: () => OrchestratorFixtureInput; + readonly adjust?: ( + steps: ReadonlyArray, + ) => ReadonlyArray; + readonly check?: (domainEvents: ReadonlyArray) => void; +}) => + Effect.gen(function* () { + if (codex === undefined) return yield* Effect.die(`The ${FIXTURE} fixture has no Codex run.`); + const recorded = yield* readProviderReplayTranscript(codex.transcriptFile).pipe( + Effect.provide(NodeServices.layer), + ); + const fixtureInput = input.buildInput(); + const workspace = yield* checkpointWorkspace(FIXTURE, fixtureInput.workspaceFiles); + const transcript = yield* CodexOrchestratorReplayHarness.decodeTranscript( + materializeReplayTranscriptWorkspace( + materializeReplayTranscriptRuntimeInstructions(recorded, { + driver: codex.driver, + model: codex.modelSelection.model, + }), + workspace, + ), + ); + const materialized = yield* materializeFixtureInput({ + scenario: FIXTURE, + fixtureInput, + driver: codex.driver, + modelSelection: codex.modelSelection, + }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime); + const result = yield* runOrchestratorV2ProviderReplayScenario( + { + name: `${FIXTURE}/${codex.driver}`, + transcript, + commands: materialized.commands, + steps: input.adjust?.(materialized.steps) ?? materialized.steps, + projectionThreadIds: materialized.projectionThreadIds, + runtimePolicyOverride: { ...codex.runtimePolicyOverride, cwd: workspace }, + }, + CodexOrchestratorReplayHarness, + ).pipe(provideDeterministicTestRuntime); + codex.assertOutput(result, transcript); + const projection = result.projections.get(materialized.projectionThreadIds[0]!); + assert.deepEqual( + projection?.runs.map((run) => run.status), + ["completed", "rolled_back", "completed"], + ); + input.check?.(result.domainEvents); + }).pipe(Effect.scoped); + +describe("admission after a rollback that reopened the provider session", () => { + if (fixture === undefined) return; + + it.effect("admits the next message on the rollback's binding once the rollback ended", () => + runRollbackAfterRestart({ + buildInput: () => { + const input = fixture.buildInput(); + const rollback = input.steps.findIndex((step) => step.type === "rollback"); + return { + ...input, + steps: [ + ...input.steps.slice(0, rollback + 1), + { type: "await_run_status", targetRunIndex: 2, status: "rolled_back" }, + ...input.steps.slice(rollback + 1), + ], + }; + }, + }), + ); + + it.effect("admits the next message on the rollback's binding while the rollback runs", () => + runRollbackAfterRestart({ + buildInput: fixture.buildInput, + // The rollback holds on its native revert's answer while the next message + // is dispatched and admitted; only then does the rollback finish. + adjust: (steps) => { + const HELD = "thread/revert"; + const message = steps.findLastIndex( + (step) => step.type === "dispatch" && step.command.type === "message.dispatch", + ); + const dispatch = steps[message]; + if (dispatch?.type !== "dispatch") return steps; + return [ + ...steps.slice(0, message), + { type: "await_replay_gate", label: HELD }, + { ...dispatch, await: true }, + { type: "release_replay_gate", label: HELD }, + ...steps.slice(message + 1), + ]; + }, + // Run 3 was admitted before the rollback projected run 2 as rolled back. + check: (events) => { + const admitted = events.findIndex( + (event) => event.type === "run.created" && event.payload.ordinal === 3, + ); + const rolledBack = events.findIndex( + (event) => + event.type === "run.updated" && + event.payload.ordinal === 2 && + event.payload.status === "rolled_back", + ); + assert.isAtLeast(admitted, 0); + assert.isAbove(rolledBack, admitted); + }, + }), + ); +}); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index dbb78eec8031..6b3ac8fe7709 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -1292,6 +1292,7 @@ export class AcpSessionRuntime extends Context.Service< */ readonly setMode: ( modeId: string, + force?: boolean, ) => Effect.Effect; /** * Updates a session configuration option and the runtime configuration snapshot. @@ -1304,12 +1305,16 @@ export class AcpSessionRuntime extends Context.Service< readonly setConfigOption: ( configId: string, value: string | boolean, + force?: boolean, ) => Effect.Effect; /** * Selects the base model through the negotiated model configuration option. * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option */ - readonly setModel: (model: string) => Effect.Effect; + readonly setModel: ( + model: string, + force?: boolean, + ) => Effect.Effect; /** * Sends a generic ACP extension request and records it through the request logger. @@ -2119,6 +2124,7 @@ export const make = ( const setConfigOption = ( configId: string, value: string | boolean, + force = false, ): Effect.Effect => validateConfigOptionValue(configId, value).pipe( Effect.flatMap(() => getStartedState), @@ -2126,7 +2132,7 @@ export const make = ( Ref.get(configOptionsRef).pipe( Effect.flatMap((configOptions) => { const existing = findSessionConfigOption(configOptions, configId); - if (existing && configOptionCurrentValueMatches(existing, value)) { + if (!force && existing && configOptionCurrentValueMatches(existing, value)) { return Effect.succeed({ configOptions, } satisfies EffectAcpSchema.SetSessionConfigOptionResponse); @@ -2746,10 +2752,10 @@ export const make = ( // A session's mode is its `category: "mode"` config option. ACP v1 agents // that only advertise `modes` (gemini-cli) take `session/set_mode` // instead, which ACP v2 removed. - setMode: (modeId) => + setMode: (modeId, force = false) => Effect.gen(function* () { const modeState = yield* Ref.get(modeStateRef); - if (modeState?.currentModeId === modeId) { + if (!force && modeState?.currentModeId === modeId) { return {} satisfies EffectAcpSchema.SetSessionModeResponse; } const modeConfigOption = (yield* Ref.get(configOptionsRef))?.find( @@ -2762,7 +2768,7 @@ export const make = ( yield* updateCurrentModeId(modeId); return {} satisfies EffectAcpSchema.SetSessionModeResponse; } - const response = yield* setConfigOption(modeConfigOption?.id ?? "mode", modeId); + const response = yield* setConfigOption(modeConfigOption?.id ?? "mode", modeId, force); // The agent answers with its config options, so the mode it reports // is the mode it runs in, even when it kept another one. const reported = parseSessionModeState({ configOptions: response.configOptions }); @@ -2789,9 +2795,11 @@ export const make = ( }), ), setConfigOption, - setModel: (model) => + setModel: (model, force = false) => getStartedState.pipe( - Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), + Effect.flatMap((started) => + setConfigOption(started.modelConfigId ?? "model", model, force), + ), Effect.asVoid, ), From e3c06fadc338293ea3850ea1249f5cd168152bab Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Wed, 23 Sep 2026 20:05:03 -0400 Subject: [PATCH 10/34] fix(mobile): Ignore child-only history in Home empty state --- .../src/features/home/HomeScreen.test.tsx | 273 ++++++++++++++++++ apps/mobile/src/features/home/HomeScreen.tsx | 4 +- 2 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/home/HomeScreen.test.tsx diff --git a/apps/mobile/src/features/home/HomeScreen.test.tsx b/apps/mobile/src/features/home/HomeScreen.test.tsx new file mode 100644 index 000000000000..86898cabf805 --- /dev/null +++ b/apps/mobile/src/features/home/HomeScreen.test.tsx @@ -0,0 +1,273 @@ +import { act, type ComponentProps, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import type { ThreadListV2ListItem } from "../threads/threadListV2"; +import { makeThreadShellFixture } from "../../test-fixtures"; + +const probe = vi.hoisted(() => ({ + empty: vi.fn(), + list: vi.fn(), + capabilities: new Set(), +})); +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, + View: ({ children }: { children?: ReactNode }) => children, + ActivityIndicator: () => null, +})); +vi.mock("@legendapp/list/react-native", () => ({ + LegendList: (props: { data: ThreadListV2ListItem[]; ListEmptyComponent: ReactNode }) => { + probe.list(props.data); + return props.data.length === 0 ? props.ListEmptyComponent : null; + }, +})); +vi.mock("../../components/EmptyState", () => ({ + EmptyState: (props: { title: string }) => { + probe.empty(props.title); + return null; + }, +})); +vi.mock("../../components/MaterialFloatingActionButton", () => ({ + MaterialFloatingActionButton: () => null, +})); +vi.mock("../../components/useAndroidControlSizing", async () => { + const { resolveAndroidControlSizing } = await import("../../lib/androidControlSizing"); + const { DEFAULT_BASE_FONT_SIZE } = await import("../../lib/appearancePreferences"); + return { useAndroidControlSizing: () => resolveAndroidControlSizing(DEFAULT_BASE_FONT_SIZE) }; +}); +vi.mock("@react-navigation/native", () => ({ useFocusEffect: () => {} })); +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0 }), +})); +vi.mock("../../native/native-glass", () => ({ NATIVE_LIQUID_GLASS_SUPPORTED: false })); +vi.mock("../../state/queries", () => ({ + useThreadSearch: () => ({ matches: [], isPending: false }), +})); +vi.mock("../keyboard/threadKeyboardShortcuts", () => ({ useThreadJumpShortcuts: () => {} })); +vi.mock("../../state/thread-order", () => ({ usePendingThreadOrder: () => null })); +vi.mock("../../state/use-thread-outbox", () => ({ useQueuedThreadKeys: () => new Set() })); +vi.mock("../../state/server", () => ({ threadListEnvironmentsAtom: {} })); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => ({ + providersByEnvironmentId: new Map(), + machineByEnvironmentId: new Map(), + settlementEnvironmentIds: probe.capabilities, + snoozeEnvironmentIds: probe.capabilities, + pinningEnvironmentIds: probe.capabilities, + pinReorderEnvironmentIds: new Set(), + activeReorderEnvironmentIds: new Set(), + titleRegenerationEnvironmentIds: new Set(), + autoSettleOptOutEnvironmentIds: new Set(), + }), +})); +vi.mock("../threads/thread-provider-instance", () => ({ + useThreadRowProviderInstanceResolver: () => () => null, +})); +vi.mock("../threads/thread-list-v2-items", () => ({ + ThreadListV2PendingRow: () => null, + ThreadListV2Row: () => null, + ThreadListV2SettledShelfHeader: () => null, + ThreadListV2ShowMoreRow: () => null, + ThreadListV2SnoozedShelfHeader: () => null, +})); +vi.mock("../threads/use-thread-list-v2-shelf-preferences", () => ({ + useThreadListV2ShelfPreferences: () => ({ + loaded: true, + settledShelfExpanded: true, + snoozedShelfExpanded: true, + toggleSettledShelf: () => {}, + toggleSnoozedShelf: () => {}, + }), +})); +vi.mock("./thread-swipe-actions", () => ({ + SwipeableScrollGateProvider: ({ children }: { children?: ReactNode }) => children, + useSwipeableScrollGate: () => ({ swipeEnabled: true, scrollGateHandlers: {} }), +})); +vi.mock("./MaterialFabScrollContext", () => ({ useMaterialFabScroll: () => () => {} })); +import { HomeScreen } from "./HomeScreen"; + +const environmentId = EnvironmentId.make("home-test"); +const projectId = ProjectId.make("home-project"); +const thread = makeThreadShellFixture({ + environmentId, + projectId, + id: ThreadId.make("root"), + title: "Root", +}); +const child = makeThreadShellFixture({ + ...thread, + id: ThreadId.make("child"), + lineage: { parentThreadId: thread.id, rootThreadId: thread.id, relationshipToParent: "subagent" }, +}); +const noop = () => {}; +const success = async () => true; +const props: ComponentProps = { + threads: [], + pendingTasks: [], + projects: [ + { + environmentId, + id: projectId, + title: "Project", + workspaceRoot: "/project", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + }, + ], + catalogState: { + isLoadingConnections: false, + hasConnections: true, + hasReadyEnvironment: true, + hasConnectingEnvironment: false, + connectingEnvironments: [], + connectionState: "connected", + connectionError: null, + networkStatus: "online", + hasLoadedShellSnapshot: true, + hasPendingShellSnapshot: false, + shellSnapshotError: null, + }, + savedConnectionsById: {}, + environments: [], + searchQuery: "", + selectedEnvironmentId: null, + selectedProjectKey: null, + projectSortOrder: "created_at", + projectGroupingMode: "repository", + onSearchQueryChange: noop, + onEnvironmentChange: noop, + onProjectChange: noop, + onAddConnection: noop, + onOpenSettings: noop, + onStartNewTask: noop, + onSelectThread: noop, + onArchiveThread: noop, + onDeleteThread: noop, + onSettleThread: success, + onSnoozeThread: success, + onUnsnoozeThread: success, + onUnsettleThread: noop, + onSetThreadAutoSettle: success, + onPinThread: success, + onUnpinThread: success, + onMoveThread: success, + onRenameThread: noop, + onRegenerateThreadTitle: success, + onSelectPendingTask: noop, + onDeletePendingTask: noop, + onNewThreadOnBranch: noop, + onNewThreadInProject: noop, +}; +let root: Root; +beforeEach(() => { + probe.empty.mockClear(); + probe.list.mockClear(); + probe.capabilities.add(environmentId); + const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); +}); +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); +async function render(overrides: Partial>) { + await act(() => root.render()); +} +it("shows the full-page empty state for child-only and archived history", async () => { + await render({ + threads: [child, { ...thread, archivedAt: thread.createdAt }], + searchQuery: "missing", + }); + expect(probe.empty).toHaveBeenLastCalledWith("No threads yet"); + expect(probe.list).not.toHaveBeenCalled(); +}); +it("keeps roots and user forks while omitting children from the current list", async () => { + const fork = { + ...thread, + id: ThreadId.make("fork"), + lineage: { ...child.lineage, relationshipToParent: "fork" as const }, + }; + await render({ + threads: [ + thread, + child, + fork, + { ...child, id: ThreadId.make("archived-child"), archivedAt: thread.createdAt }, + ], + }); + const items: ThreadListV2ListItem[] = probe.list.mock.lastCall![0]; + expect( + items.flatMap((item) => (item.type === "v2-thread" ? [item.item.thread.id] : [])).sort(), + ).toEqual([fork.id, thread.id].sort()); + expect(probe.empty).not.toHaveBeenCalled(); +}); +it.each(["pinned", "settled", "snoozed"] as const)( + "counts %s roots as global content", + async (section) => { + await render({ + threads: [ + { + ...thread, + ...(section === "pinned" ? { pinnedAt: thread.createdAt } : {}), + ...(section === "settled" + ? { settledOverride: "settled" as const, settledAt: thread.createdAt } + : {}), + ...(section === "snoozed" + ? { snoozedAt: thread.createdAt, snoozedUntil: "2099-01-01T00:00:00.000Z" } + : {}), + }, + child, + ], + }); + const items: ThreadListV2ListItem[] = probe.list.mock.lastCall![0]; + expect( + items.some((item) => item.type === "v2-thread" && item.item.thread.id === thread.id), + ).toBe(true); + expect(probe.empty).not.toHaveBeenCalled(); + }, +); +it("uses in-list no-results states without confusing filters with global emptiness", async () => { + await render({ threads: [thread, child], searchQuery: "missing" }); + expect(probe.empty).toHaveBeenLastCalledWith("No results"); + expect(probe.list).toHaveBeenLastCalledWith([]); + await render({ threads: [thread, child], selectedEnvironmentId: EnvironmentId.make("other") }); + expect(probe.empty).toHaveBeenLastCalledWith("No threads in this environment"); + expect(probe.list).toHaveBeenLastCalledWith([]); +}); +it("counts pending drafts even with only child history", async () => { + await render({ + threads: [child], + pendingTasks: [ + { + kind: "draft", + key: "pending", + environmentId, + projectId, + projectTitle: undefined, + projectCwd: undefined, + branch: null, + title: "Draft", + createdAt: thread.createdAt, + draftKey: "draft", + draft: { text: "Draft", attachments: [] }, + }, + ], + }); + const items: ThreadListV2ListItem[] = probe.list.mock.lastCall![0]; + expect(items.map((item) => item.type)).toEqual(["v2-pending"]); + expect(probe.empty).not.toHaveBeenCalled(); +}); diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index b3eddd79b10e..56b9a3739ef3 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -817,7 +817,9 @@ export function HomeScreen(props: HomeScreenProps) { // full-page "No threads yet". Settled threads are unarchived live shells, // so the archived-at check already covers the settled shelf. const hasAnyThreads = - props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; + props.threads.some( + (thread) => thread.archivedAt === null && thread.lineage.relationshipToParent !== "subagent", + ) || props.pendingTasks.length > 0; const selectedEnvironmentLabel = props.selectedEnvironmentId === null ? null From 8878be39de420bd44d57b07d588112dc551bbc41 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Wed, 23 Sep 2026 20:11:02 -0400 Subject: [PATCH 11/34] fix(mobile): Label background work as Waiting --- .../threads/thread-list-v2-items.test.tsx | 165 ++++++++++++++++++ .../features/threads/thread-list-v2-items.tsx | 1 + 2 files changed, 166 insertions(+) create mode 100644 apps/mobile/src/features/threads/thread-list-v2-items.test.tsx diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.test.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.test.tsx new file mode 100644 index 000000000000..a31bbb8e95b3 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-list-v2-items.test.tsx @@ -0,0 +1,165 @@ +import { act, type ComponentProps, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { ProviderInstanceId, RunId } from "@t3tools/contracts"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { makeThreadShellFixture } from "../../test-fixtures"; + +const rendered = vi.hoisted(() => ({ text: vi.fn() })); +vi.mock("react-native", () => ({ + View: ({ children }: { children?: ReactNode }) => children, + Pressable: ({ children }: { children?: ReactNode }) => children, + Alert: { alert: vi.fn() }, + useWindowDimensions: () => ({ width: 400 }), +})); +vi.mock("../../components/AppText", () => ({ + AppText: ({ children }: { children?: ReactNode }) => { + rendered.text(children); + return null; + }, +})); +vi.mock("../../components/RowPressable", () => ({ + RowPressable: ({ children }: { children?: ReactNode }) => children, +})); +vi.mock("../../components/ControlPill", () => ({ + ControlPillMenu: ({ children }: { children?: ReactNode }) => children, +})); +vi.mock("../home/thread-swipe-actions", () => ({ + ThreadSwipeable: ({ children }: { children: (close: () => void) => ReactNode }) => + children(() => {}), +})); +vi.mock("./CustomSnoozeSheet", () => ({ CustomSnoozeSheet: () => null })); +vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: { get: () => false } })); +vi.mock("../../state/thread-order", () => ({ threadArrangementOpenAtom: {} })); +vi.mock("../../components/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("../../components/EnvironmentMachineSymbol", () => ({ + EnvironmentMachineSymbol: () => null, +})); +vi.mock("../../components/ProjectFavicon", () => ({ ProjectFavicon: () => null })); +vi.mock("../../components/ProviderIcon", () => ({ + ProviderIcon: () => null, + ProviderInstanceIcon: () => null, +})); +vi.mock("../../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() })); +vi.mock("../../lib/useUniwindTheme", () => ({ useUniwindTheme: () => "light" })); +vi.mock("../../state/use-thread-pr", () => ({ useThreadPr: () => null })); +vi.mock("./queued-message-icon", () => ({ QueuedMessageIcon: () => null })); +vi.mock("./thread-search-match", () => ({ ThreadSearchMatchExcerpt: () => null })); +import { ThreadListV2Row } from "./thread-list-v2-items"; + +const thread = makeThreadShellFixture({ + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "test" }, +}); +const noop = () => {}; +const props: ComponentProps = { + thread, + variant: "card", + timeLabel: "", + snoozePresetMinute: "2026-06-02T00:00:00.000Z", + project: null, + providers: undefined, + providerInstance: null, + environmentLabel: null, + onSelectThread: noop, + onDeleteThread: noop, + onNewThreadOnBranch: noop, + onRenameThread: noop, + onRegenerateThreadTitle: noop, + onSettleThread: async () => true, + onSnoozeThread: noop, + onUnsnoozeThread: noop, + onUnsettleThread: noop, + onSetThreadAutoSettle: noop, + onArchiveThread: noop, + onPinThread: noop, + onUnpinThread: noop, + settlementSupported: true, + autoSettleOptOutSupported: false, + snoozeSupported: true, + pinningSupported: true, + titleRegenerationSupported: false, + onSwipeableWillOpen: noop, + onSwipeableClose: noop, +}; +let root: Root; +beforeEach(() => { + rendered.text.mockClear(); + const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); +}); +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); +async function render(overrides: Partial>) { + rendered.text.mockClear(); + await act(() => root.render()); +} +it.each(["idle", "running", "waiting"] as const)( + "renders the current %s runtime status", + async (status) => { + await render({ + thread: { + ...thread, + runtime: { + status, + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: "2026-06-02T00:00:00.000Z", + }, + }, + }); + expect(rendered.text).toHaveBeenCalledWith(status === "idle" ? "Waiting" : "Working"); + }, +); +it("keeps Done, settled timestamps and snoozed wake labels on recycled rows", async () => { + await render({ + thread: { + ...thread, + lastVisitedAt: "2026-06-01T00:00:00.000Z", + latestRun: { + runId: RunId.make("completed"), + status: "completed", + requestedAt: "2026-06-02T00:00:00.000Z", + startedAt: "2026-06-02T00:00:00.000Z", + completedAt: "2026-06-02T01:00:00.000Z", + assistantMessageId: null, + }, + }, + }); + expect(rendered.text).toHaveBeenCalledWith("Done"); + await render({ + variant: "slim", + timeLabel: "3d", + thread: { + ...thread, + settledOverride: "settled", + settledAt: thread.createdAt, + runtime: { + status: "idle", + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: "2026-06-02T00:00:00.000Z", + }, + }, + }); + expect(rendered.text).not.toHaveBeenCalledWith("Waiting"); + expect(rendered.text).toHaveBeenCalledWith("3d"); + await render({ variant: "slim", snoozed: true, snoozeWakeLabelText: "Wakes in 1h", thread }); + expect(rendered.text).toHaveBeenCalledWith("Wakes in 1h"); +}); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index a356444c2c2b..47a5e7e373d6 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -68,6 +68,7 @@ const STATUS_LABEL_BY_STATUS: Partial< approval: { label: "Approval", className: "text-warning-foreground" }, input: { label: "Input", className: "text-adaptive-indigo-600-300" }, working: { label: "Working", className: "text-adaptive-sky-600-400" }, + waiting: { label: "Waiting", className: "text-foreground-muted" }, failed: { label: "Failed", className: "text-danger-foreground" }, limited: { label: "Limited", className: "text-warning-foreground" }, }; From ff879c73b027f8a08c3230aec77114b760c78e1f Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Wed, 23 Sep 2026 20:16:41 -0400 Subject: [PATCH 12/34] fix(mobile): Surface response fork failures --- .../threads/AssistantForkButton.test.tsx | 175 ++++++++++++++++++ .../features/threads/AssistantForkButton.tsx | 107 +++++++++++ .../src/features/threads/ThreadFeed.tsx | 93 +--------- 3 files changed, 283 insertions(+), 92 deletions(-) create mode 100644 apps/mobile/src/features/threads/AssistantForkButton.test.tsx create mode 100644 apps/mobile/src/features/threads/AssistantForkButton.tsx diff --git a/apps/mobile/src/features/threads/AssistantForkButton.test.tsx b/apps/mobile/src/features/threads/AssistantForkButton.test.tsx new file mode 100644 index 000000000000..523c5c64e6f0 --- /dev/null +++ b/apps/mobile/src/features/threads/AssistantForkButton.test.tsx @@ -0,0 +1,175 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + EnvironmentId, + MessageId, + RunId, + ThreadId, + TurnItemId, + type OrchestrationV2ProjectedTurnItem, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const fixture = vi.hoisted(() => ({ + button: vi.fn(), + alert: vi.fn(), + fork: vi.fn(), + navigate: vi.fn(), + ready: vi.fn(), +})); +vi.mock("react-native", () => ({ + Pressable: (props: { onPress: () => void; disabled: boolean }) => { + fixture.button(props); + return null; + }, + Alert: { alert: fixture.alert }, + ActivityIndicator: () => null, +})); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ navigate: fixture.navigate }), +})); +vi.mock("expo-haptics", () => ({ selectionAsync: async () => {} })); +vi.mock("../../components/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("../../lib/uuid", () => ({ uuidv4: () => "target" })); +vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: { get: () => null } })); +vi.mock("../../state/threads", () => ({ + environmentThreadShells: { threadShellAtom: () => ({}) }, + threadEnvironment: { forkFromRun: {} }, +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => fixture.fork })); +vi.mock("../../state/v2-item-support", () => ({ + useV2ItemSupport: () => ({ providerSession: null }), +})); +vi.mock("./threadForkNavigation", () => ({ waitForThreadShellReady: fixture.ready })); +import { AssistantForkButton } from "./AssistantForkButton"; + +const environmentId = EnvironmentId.make("environment"); +const threadId = ThreadId.make("source"); +const itemId = TurnItemId.make("answer"); +const at = DateTime.makeUnsafe("2026-06-02T00:00:00.000Z"); +const projectedItem: OrchestrationV2ProjectedTurnItem = { + position: 0, + visibility: "local", + sourceThreadId: threadId, + sourceItemId: itemId, + item: { + id: itemId, + threadId, + runId: RunId.make("run"), + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed", + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + type: "assistant_message", + messageId: MessageId.make("answer"), + text: "Done", + streaming: false, + }, +}; +let root: Root; +beforeEach(() => { + vi.clearAllMocks(); + fixture.ready.mockResolvedValue(true); + const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); +}); +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); +async function mount() { + await act(() => + root.render( + , + ), + ); +} +it("reports an unconfirmed fork and re-enables the button without navigating", async () => { + let finish!: (result: { _tag: "Failure"; cause: Cause.Cause }) => void; + fixture.fork.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + await mount(); + await act(() => fixture.button.mock.lastCall![0].onPress()); + expect(fixture.button.mock.lastCall![0].disabled).toBe(true); + await act(async () => finish({ _tag: "Failure", cause: Cause.fail("connection lost") })); + expect(fixture.alert).toHaveBeenCalledWith( + "Could not confirm fork", + "Unable to confirm whether the forked thread was created. Check the existing thread list before retrying to avoid creating a duplicate.", + ); + expect(fixture.ready).not.toHaveBeenCalled(); + expect(fixture.navigate).not.toHaveBeenCalled(); + expect(fixture.button.mock.lastCall![0].disabled).toBe(false); +}); +it("silently clears busy after an interrupted fork without navigating", async () => { + let finish!: (result: { _tag: "Failure"; cause: Cause.Cause }) => void; + fixture.fork.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + await mount(); + await act(() => fixture.button.mock.lastCall![0].onPress()); + expect(fixture.button.mock.lastCall![0].disabled).toBe(true); + await act(async () => finish({ _tag: "Failure", cause: Cause.interrupt() })); + expect(fixture.alert).not.toHaveBeenCalled(); + expect(fixture.ready).not.toHaveBeenCalled(); + expect(fixture.navigate).not.toHaveBeenCalled(); + expect(fixture.button.mock.lastCall![0].disabled).toBe(false); +}); +it("navigates only after the fork shell is ready", async () => { + fixture.fork.mockResolvedValue({ _tag: "Success" }); + await mount(); + await act(async () => fixture.button.mock.lastCall![0].onPress()); + expect(fixture.fork).toHaveBeenCalledWith({ + environmentId, + input: { + sourceThreadId: threadId, + targetThreadId: "target", + runId: "run", + title: "Source fork", + creationSource: "mobile", + }, + }); + expect(fixture.navigate).toHaveBeenCalledWith("Thread", { environmentId, threadId: "target" }); + expect(fixture.alert).not.toHaveBeenCalled(); + expect(fixture.button.mock.lastCall![0].disabled).toBe(false); +}); +it("preserves the shell-ready timeout feedback without navigating", async () => { + fixture.fork.mockResolvedValue({ _tag: "Success" }); + fixture.ready.mockResolvedValue(false); + await mount(); + await act(async () => fixture.button.mock.lastCall![0].onPress()); + expect(fixture.alert).toHaveBeenCalledWith("Fork created", expect.stringContaining("Reconnect")); + expect(fixture.navigate).not.toHaveBeenCalled(); + expect(fixture.button.mock.lastCall![0].disabled).toBe(false); +}); diff --git a/apps/mobile/src/features/threads/AssistantForkButton.tsx b/apps/mobile/src/features/threads/AssistantForkButton.tsx new file mode 100644 index 000000000000..0350d74d982f --- /dev/null +++ b/apps/mobile/src/features/threads/AssistantForkButton.tsx @@ -0,0 +1,107 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; +import { + ThreadId, + type EnvironmentId, + type OrchestrationV2ProjectedTurnItem, +} from "@t3tools/contracts"; +import { useNavigation } from "@react-navigation/native"; +import * as Haptics from "expo-haptics"; +import { useState } from "react"; +import { ActivityIndicator, Alert, Pressable, type ColorValue } from "react-native"; +import { SymbolView } from "../../components/AppSymbol"; +import { uuidv4 } from "../../lib/uuid"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentThreadShells, threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useV2ItemSupport } from "../../state/v2-item-support"; +import { waitForThreadShellReady } from "./threadForkNavigation"; + +export function AssistantForkButton(props: { + readonly environmentId: EnvironmentId; + readonly iconColor: ColorValue; + readonly projectedItem: OrchestrationV2ProjectedTurnItem; + readonly sourceTitle: string; +}) { + const support = useV2ItemSupport({ + environmentId: props.environmentId, + sourceThreadId: props.projectedItem.sourceThreadId, + sourceItemId: props.projectedItem.sourceItemId, + }); + const forkFromRun = useAtomCommand(threadEnvironment.forkFromRun, "fork from response"); + const navigation = useNavigation(); + const [busy, setBusy] = useState(false); + const canFork = canForkProjectedAssistantItem({ + projectedItem: props.projectedItem, + capabilities: support.providerSession?.capabilities, + }); + const runId = props.projectedItem.item.runId; + if (!canFork || runId === null) return null; + return ( + { + const targetThreadId = ThreadId.make(uuidv4()); + setBusy(true); + void Haptics.selectionAsync(); + void forkFromRun({ + environmentId: props.environmentId, + input: { + sourceThreadId: props.projectedItem.sourceThreadId, + targetThreadId, + runId, + title: `${props.sourceTitle} fork`, + creationSource: "mobile", + }, + }) + .then(async (result) => { + if (isAtomCommandInterrupted(result)) return; + if (result._tag !== "Success") { + Alert.alert( + "Could not confirm fork", + "Unable to confirm whether the forked thread was created. Check the existing thread list before retrying to avoid creating a duplicate.", + ); + return; + } + const targetThreadReady = await waitForThreadShell(props.environmentId, targetThreadId); + if (!targetThreadReady) { + Alert.alert( + "Fork created", + "Its thread data did not reach this client. Reconnect and try opening it from the thread list.", + ); + return; + } + navigation.navigate("Thread", { + environmentId: props.environmentId, + threadId: targetThreadId, + }); + }) + .finally(() => setBusy(false)); + }} + hitSlop={8} + className="h-11 w-11 items-center justify-center disabled:opacity-40" + > + {busy ? ( + + ) : ( + + )} + + ); +} + +async function waitForThreadShell( + environmentId: EnvironmentId, + threadId: ThreadId, +): Promise { + const atom = environmentThreadShells.threadShellAtom(scopeThreadRef(environmentId, threadId)); + return waitForThreadShellReady({ read: () => appAtomRegistry.get(atom) !== null }); +} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 7d591d59e8c5..a3882ed889fd 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -8,9 +8,8 @@ import { import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; -import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; +import { AssistantForkButton } from "./AssistantForkButton"; import { type OrchestrationMessageContext, ThreadId, @@ -139,7 +138,6 @@ import { deriveThreadWorkLogSizing, type LayoutVariant, } from "../../lib/layout"; -import { uuidv4 } from "../../lib/uuid"; import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography, @@ -193,17 +191,12 @@ import { usePreparedConnection } from "../../state/session"; import { useThreadSelection } from "../../state/use-thread-selection"; import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; import * as Option from "effect/Option"; -import { appAtomRegistry } from "../../state/atom-registry"; -import { environmentThreadShells, threadEnvironment } from "../../state/threads"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { useV2ItemSupport } from "../../state/v2-item-support"; import { basename, fileRoutePathSegments, isAbsolutePath, resolveWorkspaceRelativeFilePath, } from "../files/filePath"; -import { waitForThreadShellReady } from "./threadForkNavigation"; import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge"; import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; import { useFileChipShare } from "./useFileChipShare"; @@ -296,90 +289,6 @@ export interface ThreadFeedProps { readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; } -async function waitForThreadShell( - environmentId: EnvironmentId, - threadId: ThreadId, -): Promise { - const atom = environmentThreadShells.threadShellAtom(scopeThreadRef(environmentId, threadId)); - return waitForThreadShellReady({ - read: () => appAtomRegistry.get(atom) !== null, - }); -} - -function AssistantForkButton(props: { - readonly environmentId: EnvironmentId; - readonly iconColor: ColorValue; - readonly projectedItem: OrchestrationV2ProjectedTurnItem; - readonly sourceTitle: string; -}) { - const support = useV2ItemSupport({ - environmentId: props.environmentId, - sourceThreadId: props.projectedItem.sourceThreadId, - sourceItemId: props.projectedItem.sourceItemId, - }); - const forkFromRun = useAtomCommand(threadEnvironment.forkFromRun, "fork from response"); - const navigation = useNavigation(); - const [busy, setBusy] = useState(false); - const canFork = canForkProjectedAssistantItem({ - projectedItem: props.projectedItem, - capabilities: support.providerSession?.capabilities, - }); - const runId = props.projectedItem.item.runId; - - if (!canFork || runId === null) return null; - - return ( - { - const targetThreadId = ThreadId.make(uuidv4()); - setBusy(true); - void Haptics.selectionAsync(); - void forkFromRun({ - environmentId: props.environmentId, - input: { - sourceThreadId: props.projectedItem.sourceThreadId, - targetThreadId, - runId, - title: `${props.sourceTitle} fork`, - creationSource: "mobile", - }, - }) - .then(async (result) => { - if (result._tag !== "Success") return; - const targetThreadReady = await waitForThreadShell(props.environmentId, targetThreadId); - if (!targetThreadReady) { - Alert.alert( - "Fork created", - "Its thread data did not reach this client. Reconnect and try opening it from the thread list.", - ); - return; - } - navigation.navigate("Thread", { - environmentId: props.environmentId, - threadId: targetThreadId, - }); - }) - .finally(() => setBusy(false)); - }} - className="h-7 w-7 items-center justify-center disabled:opacity-40" - > - {busy ? ( - - ) : ( - - )} - - ); -} - function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; From 06e5681e18dbcd5d30b1d34f20fba140cf62171a Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Wed, 23 Sep 2026 20:20:53 -0400 Subject: [PATCH 13/34] fix(mobile): Import legacy model options without replacing choices --- .../src/state/use-composer-drafts.test.ts | 172 +++++++++++++++++- apps/mobile/src/state/use-composer-drafts.ts | 99 ++++++++-- 2 files changed, 249 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index ec79cf043191..2f855b118dc8 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -9,9 +9,13 @@ import { ThreadId, } from "@t3tools/contracts"; import { onTestFinished, vi } from "vite-plus/test"; +import { resolveNewTaskModelSelection } from "../lib/modelOptions"; const composerDraftFileMocks = vi.hoisted(() => { let document = JSON.stringify({ schemaVersion: 1, drafts: {} }); + let legacyDocument: string | null = null; + let legacyReadError: Error | null = null; + const legacyReads = vi.fn(); let readError: Error | null = null; let writeError: Error | null = null; let releaseRead: (() => void) | null = null; @@ -23,6 +27,13 @@ const composerDraftFileMocks = vi.hoisted(() => { return { readImage, + legacyReads, + setLegacyDocument(value: string | null) { + legacyDocument = value; + }, + setLegacyReadError(error: Error | null) { + legacyReadError = error; + }, blockRead() { readBarrier = new Promise((resolve) => { releaseRead = resolve; @@ -64,15 +75,32 @@ const composerDraftFileMocks = vi.hoisted(() => { } }, File: class { - exists = true; + readonly legacy: boolean; + constructor(_directory: unknown, fileName: string) { + this.legacy = fileName === "model-option-memory.json"; + } + get exists() { + return !this.legacy || legacyDocument !== null; + } parentDirectory = null; create() {} - moveSync() {} + moveSync() { + if (this.legacy) throw new Error("Legacy memory must remain read-only"); + } + + delete() { + if (this.legacy) throw new Error("Legacy memory must remain read-only"); + } async text() { await readBarrier; + if (this.legacy) { + legacyReads(); + if (legacyReadError) throw legacyReadError; + return legacyDocument!; + } if (readError) throw readError; return document; } @@ -82,6 +110,7 @@ const composerDraftFileMocks = vi.hoisted(() => { } write(value: string) { + if (this.legacy) throw new Error("Legacy memory must remain read-only"); if (writeError) { throw writeError; } @@ -206,6 +235,9 @@ afterEach(() => { resetComposerDraftsLoadState(); composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); composerDraftFileMocks.setReadError(null); + composerDraftFileMocks.setLegacyDocument(null); + composerDraftFileMocks.setLegacyReadError(null); + composerDraftFileMocks.legacyReads.mockClear(); composerDraftFileMocks.setWriteError(null); composerDraftFileMocks.setNextWriteBarrier(null); composerDraftFileMocks.setOnWrite(null); @@ -1956,6 +1988,142 @@ describe("mobile composer drafts", () => { }); }); + it("imports trial memory once, with per-model disk and in-flight choices winning", async () => { + vi.useFakeTimers(); + const options = (value: string) => [{ id: "thinking", value }]; + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { "environment-1:saved": DRAFT }, + modelOptionMemory: { pi: { a: options("high"), b: options("medium") } }, + }); + composerDraftFileMocks.setLegacyDocument( + JSON.stringify({ + schemaVersion: 1, + byInstance: { + pi: { a: options("low"), b: options("low"), c: options("off"), empty: [] }, + other: { a: options("max") }, + }, + }), + ); + composerDraftFileMocks.blockRead(); + ensureComposerDraftsLoaded(); + appAtomRegistry.set(modelOptionMemoryAtom, { pi: { a: options("xhigh") } }); + composerDraftFileMocks.releaseRead(); + await waitForComposerDraftsLoaded(); + const expected = { + pi: { a: options("xhigh"), b: options("medium"), c: options("off") }, + other: { a: options("max") }, + }; + expect(appAtomRegistry.get(modelOptionMemoryAtom)).toEqual(expected); + // A failed migration write leaves saved drafts retryable. + composerDraftFileMocks.setWriteError(new Error("disk unavailable")); + await expect(flushComposerDrafts()).rejects.toMatchObject({ operation: "write" }); + expect( + JSON.parse(composerDraftFileMocks.getDocument()).legacyModelOptionMemoryImported, + ).toBeUndefined(); + composerDraftFileMocks.setWriteError(null); + await flushComposerDrafts(); + expect(JSON.parse(composerDraftFileMocks.getDocument())).toMatchObject({ + drafts: { "environment-1:saved": DRAFT }, + modelOptionMemory: expected, + legacyModelOptionMemoryImported: true, + }); + resetComposerDraftsLoadState(); + appAtomRegistry.set(modelOptionMemoryAtom, {}); + composerDraftFileMocks.legacyReads.mockClear(); + await waitForComposerDraftsLoaded(); + expect(appAtomRegistry.get(modelOptionMemoryAtom)).toEqual(expected); + expect(composerDraftFileMocks.legacyReads).not.toHaveBeenCalled(); + }); + + it.each(["fresh", "existing", "explicit"] as const)( + "imports legacy options without replacing the %s draft selection or sticky priority", + async (kind) => { + vi.useFakeTimers(); + const selection = { + instanceId: ProviderInstanceId.make("codex"), + model: "chosen", + options: [{ id: "reasoning", value: "high" }], + }; + const draft = { + text: "Keep this prompt", + attachments: [], + modelSelection: selection, + interactionMode: "plan" as const, + }; + const key = kind === "existing" ? "environment-1:thread" : "new-task:chosen"; + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: kind === "fresh" ? {} : { [key]: draft }, + stickyModelSelection: selection, + }); + composerDraftFileMocks.setLegacyDocument( + JSON.stringify({ + schemaVersion: 1, + byInstance: { codex: { chosen: [{ id: "reasoning", value: "low" }] } }, + }), + ); + await waitForComposerDraftsLoaded(); + const restored = appAtomRegistry.get(composerDraftsAtom)[key]; + expect(restored).toEqual(kind === "fresh" ? undefined : draft); + expect(appAtomRegistry.get(stickyComposerModelSelectionAtom)).toEqual(selection); + expect(appAtomRegistry.get(modelOptionMemoryAtom)).toEqual({ + codex: { chosen: [{ id: "reasoning", value: "low" }] }, + }); + expect( + resolveNewTaskModelSelection({ + draftSelection: restored?.modelSelection ?? null, + stickySelection: appAtomRegistry.get(stickyComposerModelSelectionAtom), + projectDefaultSelection: null, + modelOptions: [], + }), + ).toEqual(selection); + await flushComposerDrafts(); + expect(JSON.parse(composerDraftFileMocks.getDocument()).stickyModelSelection).toEqual( + selection, + ); + }, + ); + + it.each(["json", "schema", "read"] as const)( + "preserves drafts and retries the import after a legacy %s failure", + async (failure) => { + vi.useFakeTimers(); + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { "environment-1:saved": DRAFT }, + }); + composerDraftFileMocks.setLegacyDocument( + failure === "json" ? "{" : JSON.stringify({ schemaVersion: 999, byInstance: {} }), + ); + if (failure === "read") composerDraftFileMocks.setLegacyReadError(new Error("unavailable")); + setComposerDraftText("environment-1:new", "New edits"); + await flushComposerDrafts(); + expect(JSON.parse(composerDraftFileMocks.getDocument())).toMatchObject({ + drafts: { "environment-1:saved": DRAFT, "environment-1:new": { text: "New edits" } }, + }); + expect( + JSON.parse(composerDraftFileMocks.getDocument()).legacyModelOptionMemoryImported, + ).toBeUndefined(); + resetComposerDraftsLoadState(); + composerDraftFileMocks.setLegacyReadError(null); + composerDraftFileMocks.setLegacyDocument( + JSON.stringify({ + schemaVersion: 1, + byInstance: { pi: { a: [{ id: "thinking", value: "max" }] } }, + }), + ); + await flushComposerDrafts(); + expect(JSON.parse(composerDraftFileMocks.getDocument()).legacyModelOptionMemoryImported).toBe( + true, + ); + expect(appAtomRegistry.get(composerDraftsAtom)["environment-1:saved"]).toEqual(DRAFT); + expect(JSON.parse(composerDraftFileMocks.getDocument()).modelOptionMemory).toEqual({ + pi: { a: [{ id: "thinking", value: "max" }] }, + }); + }, + ); + it("waits for hydration before persisting the latest composer state", async () => { vi.useFakeTimers(); composerDraftFileMocks.setDocument({ diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 780485c0718f..df48de3f1b9b 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -398,16 +398,21 @@ const ComposerDraftSchema = Schema.Struct({ project: Schema.optional(ComposerDraftProjectSchema), }); +const ModelOptionMemorySchema = Schema.Record( + Schema.String, + Schema.Record(Schema.String, Schema.Array(ProviderOptionSelectionSchema)), +); +const LEGACY_MODEL_OPTION_MEMORY_FILE = "model-option-memory.json"; +const decodeLegacyModelOptionMemory = Schema.decodeUnknownSync( + Schema.Struct({ schemaVersion: Schema.Literal(1), byInstance: ModelOptionMemorySchema }), +); + const PersistedComposerDraftsSchema = Schema.Struct({ schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), drafts: Schema.Record(Schema.String, ComposerDraftSchema), stickyModelSelection: Schema.optional(ModelSelectionSchema), - modelOptionMemory: Schema.optional( - Schema.Record( - Schema.String, - Schema.Record(Schema.String, Schema.Array(ProviderOptionSelectionSchema)), - ), - ), + modelOptionMemory: Schema.optional(ModelOptionMemorySchema), + legacyModelOptionMemoryImported: Schema.optional(Schema.Literal(true)), cloudAccountId: Schema.optional(Schema.String), signedOutDrafts: Schema.optional( Schema.Record( @@ -466,12 +471,14 @@ export const composerCloudDraftsAtom = Atom.make({ let loadPromise: Promise | null = null; let persistTimer: ReturnType | null = null; let persistRetryNeeded = false; +let legacyModelOptionMemoryImported = false; const persistenceQueue = new SerializedAsyncQueue(); /** Resets module-level state between test runs. */ export function resetComposerDraftsLoadState(): void { loadPromise = null; persistRetryNeeded = false; + legacyModelOptionMemoryImported = false; } function attachmentContextRecord( @@ -627,6 +634,7 @@ export function decodePersistedComposerState(value: unknown): { readonly drafts: Record; readonly stickyModelSelection: ModelSelection | null; readonly modelOptionMemory: ModelOptionMemoryState; + readonly legacyModelOptionMemoryImported?: true; readonly cloudDrafts: ComposerCloudDraftState; } { const parsed = decodePersistedComposerDraftsDocument(value); @@ -666,6 +674,7 @@ export function decodePersistedComposerState(value: unknown): { ), stickyModelSelection: parsed.stickyModelSelection ?? null, modelOptionMemory: parsed.modelOptionMemory ?? {}, + ...(parsed.legacyModelOptionMemoryImported ? { legacyModelOptionMemoryImported: true } : {}), cloudDrafts: { accountId: parsed.cloudAccountId ?? null, signedOut: Object.fromEntries( @@ -687,11 +696,57 @@ export function decodePersistedComposerState(value: unknown): { }; } -async function getComposerDraftsFile() { +async function getComposerDraftsFile(fileName = COMPOSER_DRAFTS_FILE) { const { Directory, File, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY); directory.create({ idempotent: true, intermediates: true }); - return new File(directory, COMPOSER_DRAFTS_FILE); + return new File(directory, fileName); +} + +function mergeModelOptionMemory( + persisted: ModelOptionMemoryState, + current: ModelOptionMemoryState, +): ModelOptionMemoryState { + return { + ...persisted, + ...Object.fromEntries( + Object.entries(current).map(([instanceId, models]) => [ + instanceId, + { ...persisted[instanceId], ...models }, + ]), + ), + }; +} + +async function loadLegacyModelOptionMemory(): Promise { + let operation: ComposerDraftPersistenceError["operation"] = "open"; + try { + const file = await getComposerDraftsFile(LEGACY_MODEL_OPTION_MEMORY_FILE); + if (!file.exists) return null; + operation = "read"; + const raw = await file.text(); + operation = "decode"; + const { byInstance } = decodeLegacyModelOptionMemory(JSON.parse(raw) as unknown); + return Object.fromEntries( + Object.entries(byInstance).map(([instanceId, models]) => [ + instanceId, + Object.fromEntries(Object.entries(models).filter(([, options]) => options.length > 0)), + ]), + ); + } catch (cause) { + // Legacy option memory must never prevent saved drafts from hydrating. Leave + // the import unmarked on failure so a later launch can retry the old file. + console.warn( + "[composer-drafts] could not import legacy model options", + new ComposerDraftPersistenceError({ + operation, + directory: COMPOSER_DRAFTS_DIRECTORY, + fileName: LEGACY_MODEL_OPTION_MEMORY_FILE, + cause, + }), + ); + return null; + } } async function loadPersistedComposerState(): Promise< @@ -737,6 +792,7 @@ async function writePersistedComposerState( const document = { schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, + ...(legacyModelOptionMemoryImported ? { legacyModelOptionMemoryImported: true } : {}), ...(stickyModelSelection ? { stickyModelSelection } : {}), ...(Object.keys(appAtomRegistry.get(modelOptionMemoryAtom)).length > 0 ? { modelOptionMemory: appAtomRegistry.get(modelOptionMemoryAtom) } @@ -1028,7 +1084,13 @@ export function ensureComposerDraftsLoaded(): void { if (loadPromise !== null) { return; } - const loading = loadPersistedComposerState().then((persisted) => { + const loading = loadPersistedComposerState().then(async (persisted) => { + const legacyMemory = persisted.legacyModelOptionMemoryImported + ? null + : await loadLegacyModelOptionMemory(); + const persistedMemory = mergeModelOptionMemory(legacyMemory ?? {}, persisted.modelOptionMemory); + legacyModelOptionMemoryImported = + persisted.legacyModelOptionMemoryImported === true || legacyMemory !== null; appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts); if (Object.keys(persisted.drafts).length > 0) { const current = appAtomRegistry.get(composerDraftsAtom); @@ -1043,18 +1105,15 @@ export function ensureComposerDraftsLoaded(): void { ) { appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection); } - if (Object.keys(persisted.modelOptionMemory).length > 0) { - const current = appAtomRegistry.get(modelOptionMemoryAtom); - appAtomRegistry.set(modelOptionMemoryAtom, { - ...persisted.modelOptionMemory, - ...Object.fromEntries( - Object.entries(current).map(([instanceId, models]) => [ - instanceId, - { ...(persisted.modelOptionMemory[instanceId] ?? {}), ...models }, - ]), - ), - }); + if (Object.keys(persistedMemory).length > 0) { + appAtomRegistry.set( + modelOptionMemoryAtom, + mergeModelOptionMemory(persistedMemory, appAtomRegistry.get(modelOptionMemoryAtom)), + ); } + // The marker and imported choices land atomically with drafts through the + // existing retryable writer. Never delete or write the legacy file. + if (legacyMemory !== null) schedulePersistComposerState(); }); loadPromise = loading; // Handle fire-and-forget hook loads without swallowing failures from the From b9e090b321faa69737fd4049d4a6f7da9b1498c8 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Wed, 23 Sep 2026 20:23:04 -0400 Subject: [PATCH 14/34] fix(clients): Present legacy wakes as notifications --- apps/mobile/src/lib/threadActivity.test.ts | 58 ++++++++ apps/mobile/src/lib/threadActivity.ts | 14 +- apps/web/src/session-logic.test.ts | 49 +++++++ apps/web/src/session-logic.ts | 13 +- .../src/state/turnItemPresentation.test.ts | 133 +++++++++++++++++- .../src/state/turnItemPresentation.ts | 52 ++++++- packages/contracts/src/orchestrationV2.ts | 9 ++ 7 files changed, 318 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 425605a471cd..93ba51edcd01 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2075,6 +2075,64 @@ it("renders automatic completion as a neutral activity while retaining its detai expect(buildThreadFeed([projected(userMessage(), 0)])[0]?.type).toBe("message"); }); +it("keeps historical trial wakes outside compact folds without hiding user text", () => { + const wake: OrchestrationV2TurnItem = { + ...userMessage(), + type: "user_message", + messageId: MessageId.make("legacy-wake"), + createdBy: "agent", + creationSource: "provider", + inputIntent: "turn_start", + attachments: [], + text: "Background task completed.", + providerWake: { kind: "background_task", count: 1 }, + }; + const feed = buildThreadFeed( + [projected(wake, 0), projected(command(), 1), projected(assistantMessage(), 2)], + { + anchoredMessages: [ + { + id: wake.messageId, + role: "user", + text: wake.text, + streaming: false, + createdAt: "2026-06-20T00:00:01.000Z", + updatedAt: "2026-06-20T00:00:01.000Z", + }, + ], + }, + ); + expect(feed.some((entry) => entry.type === "message" && entry.id === wake.messageId)).toBe(false); + const attributed = buildThreadFeed([projected({ ...wake, senderThreadId: sourceThreadId }, 0)]); + expect(attributed).toHaveLength(1); + expect(attributed[0]).toMatchObject({ + type: "message", + message: { text: wake.text, senderThreadId: sourceThreadId, sourceThreadId: threadId }, + }); + expect(feed[0]?.type).toBe("activity-group"); + const presented = deriveThreadFeedPresentation( + feed, + { + runId, + status: "completed", + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: "2026-06-20T00:00:03.000Z", + }, + new Set(), + ); + expect( + presented.some( + (entry) => + entry.type === "activity-group" && + entry.activities.some((activity) => activity.summary === "Background task finished"), + ), + ).toBe(true); + expect( + buildThreadFeed([projected({ ...wake, createdBy: "user", creationSource: "mobile" }, 0)])[0] + ?.type, + ).toBe("message"); +}); + it("uses a compact reasoning preview and a short expanded heading", () => { const entry = { id: "thought", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 6608f797eea9..06593f5ed4ac 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -3,7 +3,10 @@ import type { ThreadPendingUserInput, ThreadUserInputQuestion, } from "@t3tools/client-runtime/state/thread-requests"; -import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; +import { + presentLegacyWakeItem, + turnItemIsWorkspacePreparation, +} from "@t3tools/client-runtime/state/turn-item-presentation"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; import { @@ -1626,8 +1629,12 @@ export function buildThreadFeed( : [], ), ); + const legacyWakeMessageIds = new Set(); for (const row of visibleTurnItems) { - const item = row.item; + const item = presentLegacyWakeItem(row.item); + if (row.item.type === "user_message" && item.type === "notification") { + legacyWakeMessageIds.add(row.item.messageId); + } if (turnItemIsWorkspacePreparation(item)) continue; if (item.type === "todo_list" || item.type === "checkpoint") continue; if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue; @@ -1677,7 +1684,7 @@ export function buildThreadFeed( entries.push(entry); continue; } - const activity = toFeedActivity(row, attemptId); + const activity = toFeedActivity(item === row.item ? row : { ...row, item }, attemptId); const entry: RawThreadFeedEntry = { type: "activity", id: activity.id, @@ -1690,6 +1697,7 @@ export function buildThreadFeed( } const retainedMessageIds = new Set([ ...foldedAnswerMessageIds, + ...legacyWakeMessageIds, ...entries.flatMap((entry) => (entry.type === "message" ? [entry.id] : [])), ]); const appendLocalMessage = (message: LocalThreadMessage): RawThreadFeedEntry => { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index c71cc4f33573..269ca2288bcd 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1668,5 +1668,54 @@ it("renders automatic completion as a work entry instead of a user bubble", () = }, ], })[0]?.kind, + ).toBe("work"); + const ordinaryUser = { + ...item, + type: "user_message" as const, + messageId: MessageId.make("ordinary-user"), + createdBy: "user" as const, + creationSource: "web" as const, + inputIntent: "turn_start" as const, + attachments: [], + text: "Delegated task node:task-1 reached a terminal state.", + }; + expect( + deriveTimelineEntriesFromVisibleTurnItems({ + optimisticMessages: [], + visibleTurnItems: [{ ...row, item: ordinaryUser }], + })[0]?.kind, ).toBe("message"); + const legacyWake = { + ...ordinaryUser, + createdBy: "agent" as const, + creationSource: "server" as const, + }; + const optimisticMessages = [ + { + id: legacyWake.messageId, + role: "user" as const, + text: legacyWake.text, + runId: null, + streaming: false, + createdAt: "2026-09-09T00:00:00Z", + updatedAt: "2026-09-09T00:00:00Z", + }, + ]; + const legacyEntries = deriveTimelineEntriesFromVisibleTurnItems({ + optimisticMessages, + visibleTurnItems: [{ ...row, item: legacyWake }], + }); + expect(legacyEntries).toHaveLength(1); + expect(legacyEntries[0]?.kind).toBe("work"); + const attributedEntries = deriveTimelineEntriesFromVisibleTurnItems({ + optimisticMessages, + visibleTurnItems: [ + { ...row, item: { ...legacyWake, senderThreadId: ThreadId.make("sender") } }, + ], + }); + expect(attributedEntries).toHaveLength(1); + expect(attributedEntries[0]).toMatchObject({ + kind: "message", + message: { text: legacyWake.text, senderThreadId: "sender" }, + }); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 592eece2dce2..72c2e3a3ec97 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -31,7 +31,10 @@ import type { } from "@t3tools/client-runtime/state/thread-requests"; import type { ThreadRunSummary, ThreadRuntimeSummary } from "@t3tools/client-runtime/state/shell"; import { threadRuntimeHasInterruptibleRun } from "@t3tools/client-runtime/state/thread-execution"; -import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; +import { + presentLegacyWakeItem, + turnItemIsWorkspacePreparation, +} from "@t3tools/client-runtime/state/turn-item-presentation"; import { isImageAttachment, @@ -595,8 +598,12 @@ export function deriveTimelineEntriesFromVisibleTurnItems( : [], ), ); - for (const row of input.visibleTurnItems) { - const { item } = row; + for (const sourceRow of input.visibleTurnItems) { + const item = presentLegacyWakeItem(sourceRow.item); + const row = item === sourceRow.item ? sourceRow : { ...sourceRow, item }; + if (sourceRow.item.type === "user_message" && item.type === "notification") { + committedMessageIds.add(sourceRow.item.messageId); + } if (turnItemIsWorkspacePreparation(item)) continue; // Task progress belongs in the composer, not between conversation entries. if (item.type === "todo_list" || item.type === "checkpoint") continue; diff --git a/packages/client-runtime/src/state/turnItemPresentation.test.ts b/packages/client-runtime/src/state/turnItemPresentation.test.ts index af2b9fcbe83b..4b7165987567 100644 --- a/packages/client-runtime/src/state/turnItemPresentation.test.ts +++ b/packages/client-runtime/src/state/turnItemPresentation.test.ts @@ -1,10 +1,22 @@ -import { RunId, ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/contracts"; +import { + MessageId, + OrchestrationV2TurnItem, + OrchestrationV2TurnItemJson, + RunId, + ThreadId, + TurnItemId, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; -import { turnItemIsWorkspacePreparation } from "./turnItemPresentation.ts"; +import { presentLegacyWakeItem, turnItemIsWorkspacePreparation } from "./turnItemPresentation.ts"; -function command(input: string): OrchestrationV2TurnItem { +const encodeTurnItemJson = Schema.encodeSync(OrchestrationV2TurnItemJson); +const decodeDurableTurnItem = Schema.decodeSync(Schema.fromJsonString(OrchestrationV2TurnItemJson)); +const decodeWireTurnItem = Schema.decodeSync(OrchestrationV2TurnItem); + +function command(input: string): Extract { const now = DateTime.makeUnsafe("2026-08-03T00:00:00.000Z"); return { id: TurnItemId.make("item-command"), @@ -28,6 +40,121 @@ function command(input: string): OrchestrationV2TurnItem { }; } +function wake( + overrides: Partial> = {}, +): Extract { + return { + ...command("ignored"), + type: "user_message", + messageId: MessageId.make("old-wake"), + createdBy: "agent", + creationSource: "provider", + inputIntent: "turn_start", + text: "Background task completed.", + attachments: [], + ...overrides, + }; +} + +describe("legacy trial wake presentation", () => { + it("preserves authoritative metadata through durable JSON and live wire decoding", () => { + const original = wake({ + text: "Provider-specific payload without the sentinel", + providerWake: { kind: "background_command", count: 2 }, + }); + const encoded = encodeTurnItemJson(original); + const durable = decodeDurableTurnItem(JSON.stringify(encoded)); + const wire = decodeWireTurnItem(durable); + expect(wire).toMatchObject({ + providerWake: original.providerWake, + createdBy: "agent", + creationSource: "provider", + }); + expect(presentLegacyWakeItem(wire)).toMatchObject({ + type: "notification", + source: { kind: "command" }, + summary: "2 background tasks finished", + detail: original.text, + }); + expect(wire.type).toBe("user_message"); + }); + + it.each([ + ["Background task completed.", "provider", "background_task", "Background task finished"], + [ + "Background command completed (exit -1): command", + "provider", + "command", + "Background task finished", + ], + [ + "Delegated task node:task-1 reached a terminal state. Read the result.", + "server", + "delegated_task", + "Delegated task finished", + ], + [ + "Delegated tasks node:task-1, node:task-2 reached terminal states.", + "server", + "delegated_task", + "2 delegated tasks finished", + ], + ] as const)( + "recognizes the old %s sentinel only with authoritative provenance", + (text, creationSource, kind, summary) => { + expect(presentLegacyWakeItem(wake({ text, creationSource }))).toMatchObject({ + type: "notification", + source: { kind }, + summary, + }); + const user = wake({ text, creationSource, createdBy: "user" }); + expect(presentLegacyWakeItem(user)).toBe(user); + const web = wake({ text, creationSource: "web" }); + expect(presentLegacyWakeItem(web)).toBe(web); + }, + ); + + it("does not trust wake-shaped metadata on an ordinary user or unrelated agent message", () => { + for (const item of [ + wake({ createdBy: "user", providerWake: { kind: "background_task", count: 1 } }), + wake({ text: "Explain this example: Background task completed." }), + wake({ creationSource: "server" }), + ]) + expect(presentLegacyWakeItem(item)).toBe(item); + }); + + it.each([ + ["provider", "Background task completed."], + ["provider", "Background command completed (exit 0): build"], + ["server", "Delegated task node:task-1 reached a terminal state."], + ] as const)( + "retains sender attribution on %s messages resembling wakes", + (creationSource, text) => { + const original = wake({ + creationSource, + text, + senderThreadId: ThreadId.make("sender-thread"), + providerWake: { kind: "background_task", count: 1 }, + }); + const durable = decodeDurableTurnItem(JSON.stringify(encodeTurnItemJson(original))); + const wire = decodeWireTurnItem(durable); + expect(wire).toMatchObject({ senderThreadId: original.senderThreadId }); + expect(presentLegacyWakeItem(wire)).toBe(wire); + }, + ); + + it("leaves upstream typed notifications unchanged", () => { + const item: OrchestrationV2TurnItem = { + ...command("ignored"), + type: "notification", + source: { kind: "monitor" }, + outcome: "failed", + summary: "Build failed", + }; + expect(presentLegacyWakeItem(item)).toBe(item); + }); +}); + describe("turnItemIsWorkspacePreparation", () => { it("identifies the synthetic workspace preparation command", () => { expect(turnItemIsWorkspacePreparation(command("Preparing workspace"))).toBe(true); diff --git a/packages/client-runtime/src/state/turnItemPresentation.ts b/packages/client-runtime/src/state/turnItemPresentation.ts index fe306c92ae34..c086ea487a85 100644 --- a/packages/client-runtime/src/state/turnItemPresentation.ts +++ b/packages/client-runtime/src/state/turnItemPresentation.ts @@ -1,7 +1,57 @@ -import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; +import { + NodeId, + type OrchestrationV2Notification, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; const WORKSPACE_PREPARATION_INPUT = "Preparing workspace"; +/** Present old trial wakes through the notification UI, without changing durable data. */ +export function presentLegacyWakeItem(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { + if (item.type !== "user_message" || item.createdBy !== "agent") return item; + if (item.senderThreadId !== undefined) return item; + + let notification: OrchestrationV2Notification | undefined; + if (item.creationSource === "provider") { + const commandCount = [ + ...item.text.matchAll(/^Background command completed(?: \(exit -?\d+\))?:/gmu), + ].length; + const taskCount = [...item.text.matchAll(/^Background task completed\./gmu)].length; + const legacyKind = + item.providerWake?.kind ?? (commandCount > 0 ? "background_command" : "background_task"); + const count = item.providerWake?.count ?? commandCount + taskCount; + if (count > 0) { + notification = { + source: { kind: legacyKind === "background_command" ? "command" : "background_task" }, + outcome: "unknown", + summary: count > 1 ? `${count} background tasks finished` : "Background task finished", + detail: item.text, + }; + } + } else if (item.creationSource === "server") { + const taskIds = [ + ...new Set( + [...item.text.matchAll(/^Delegated tasks? (.+?) reached (?:a )?terminal states?\./gmu)] + .flatMap((match) => (match[1] ?? "").split(", ")) + .map((id) => id.trim()) + .filter((id) => id.length > 0), + ), + ].map((id) => NodeId.make(id)); + if (taskIds.length > 0) { + notification = { + source: { kind: "delegated_task", taskIds }, + outcome: "unknown", + summary: + taskIds.length > 1 + ? `${taskIds.length} delegated tasks finished` + : "Delegated task finished", + detail: item.text, + }; + } + } + return notification === undefined ? item : { ...item, type: "notification", ...notification }; +} + /** Workspace setup is client bookkeeping; preparation failures have their own error item. */ export function turnItemIsWorkspacePreparation(item: OrchestrationV2TurnItem): boolean { return item.type === "command_execution" && item.input === WORKSPACE_PREPARATION_INPUT; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index cafc2bb19945..f9f78baa11b8 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1243,6 +1243,13 @@ export const OrchestrationV2WebSearchResult = Schema.Struct({ }); export type OrchestrationV2WebSearchResult = typeof OrchestrationV2WebSearchResult.Type; +// Read compatibility for historical trial user-message wakes only. New events +// use OrchestrationV2Notification; this does not restore a wake producer. +const LegacyProviderWake = Schema.Struct({ + kind: Schema.Literals(["background_command", "background_task"]), + count: PositiveInt, +}); + export const OrchestrationV2TurnItem = Schema.Union([ Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, @@ -1252,6 +1259,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, ...OrchestrationV2CreationFields, + providerWake: Schema.optional(LegacyProviderWake), type: Schema.Literal("user_message"), messageId: MessageId, scheduledTaskId: Schema.optional(ScheduledTaskId), @@ -1975,6 +1983,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ Schema.Struct({ ...OrchestrationV2TurnItemJsonBaseFields, ...OrchestrationV2CreationFields, + providerWake: Schema.optional(LegacyProviderWake), type: Schema.Literal("user_message"), messageId: MessageId, scheduledTaskId: Schema.optional(ScheduledTaskId), From 42a3fe0a5a0003001cc6e6f992fab042abfb2bcf Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Fri, 25 Sep 2026 01:59:11 -0400 Subject: [PATCH 15/34] fix(orchestrator): Preserve error occurrences through session cleanup --- .../Adapters/PiAdapterV2.test.ts | 268 +++++++++++++++++- .../orchestration-v2/Adapters/PiAdapterV2.ts | 20 +- .../orchestration-v2/ProjectionStore.test.ts | 17 +- .../src/orchestration-v2/ProjectionStore.ts | 47 ++- .../ProviderSessionManager.test.ts | 195 ++++++++++++- .../ProviderSessionManager.ts | 71 ++++- .../ErrorOccurrence.integration.test.ts | 88 ++++++ .../testkit/ProviderReplayHarness.ts | 8 +- apps/web/src/components/ChatView.tsx | 132 ++------- .../chat/ThreadErrorBanner.test.tsx | 60 ++++ .../src/components/chat/ThreadErrorBanner.tsx | 61 +++- .../chat/useThreadErrorState.test.tsx | 96 +++++++ .../components/chat/useThreadErrorState.ts | 82 ++++++ packages/client-runtime/src/state/models.ts | 2 + .../src/state/threadExecution.test.ts | 52 ++++ .../src/state/threadExecution.ts | 15 +- packages/contracts/src/orchestrationV2.ts | 3 + .../shared/src/orchestrationV2ThreadError.ts | 24 +- 18 files changed, 1087 insertions(+), 154 deletions(-) create mode 100644 apps/server/src/orchestration-v2/testkit/ErrorOccurrence.integration.test.ts create mode 100644 apps/web/src/components/chat/useThreadErrorState.test.tsx create mode 100644 apps/web/src/components/chat/useThreadErrorState.ts diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index faf1ba01c3c2..8f9e23fb2037 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -29,18 +29,28 @@ import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import * as Deferred from "effect/Deferred"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as ServerConfig from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as IdAllocator from "../IdAllocator.ts"; +import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import * as EventSink from "../EventSink.ts"; +import * as EventStore from "../EventStore.ts"; +import * as ProjectionStore from "../ProjectionStore.ts"; +import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; +import * as ProviderEventIngestor from "../ProviderEventIngestor.ts"; +import * as ProviderSessionManager from "../ProviderSessionManager.ts"; +import { ProviderAdapterEventStreamError } from "../ProviderAdapter.ts"; import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2Event, type ProviderAdapterV2SessionRuntime, } from "../ProviderAdapter.ts"; import { handoffBudget } from "../ContextHandoffBudget.ts"; -import { makePiAdapterV2, PI_PROVIDER } from "./PiAdapterV2.ts"; +import { makePiAdapterV2, PI_PROVIDER, piLastErrorAt } from "./PiAdapterV2.ts"; import { makePiRpcConnection, type PiRpcRecord } from "./PiRpc.ts"; const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -347,8 +357,10 @@ const openRuntime = Effect.fnUntraced(function* ( runtimePolicy, }); const emitted = yield* Queue.unbounded(); + const eventsEnded = yield* Deferred.make(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(emitted, event)), + Effect.ensuring(Deferred.succeed(eventsEnded, undefined)), Effect.forkScoped, ); const takeEvent = (predicate: (event: ProviderAdapterV2Event) => boolean) => @@ -358,7 +370,7 @@ const openRuntime = Effect.fnUntraced(function* ( if (predicate(event)) return event; } }); - return { runtime, takeEvent }; + return { runtime, takeEvent, eventsEnded: Deferred.await(eventsEnded) }; }); const makeAppThread = Effect.fnUntraced(function* (model: string, threadId = THREAD_ID) { @@ -450,7 +462,10 @@ const expectModelFailure = (errorMessage: string) => ); assert.isTrue( sessionError.type === "provider_session.updated" && - sessionError.providerSession.lastError === errorMessage, + sessionError.providerSession.lastError === errorMessage && + sessionError.providerSession.lastErrorAt != null && + DateTime.toEpochMillis(sessionError.providerSession.lastErrorAt) === + DateTime.toEpochMillis(sessionError.providerSession.updatedAt), ); const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); assert.isTrue( @@ -461,6 +476,253 @@ const expectModelFailure = (errorMessage: string) => }).pipe(Effect.scoped, Effect.provide(testLayer)); describe("PiAdapterV2", () => { + it("keeps the occurrence identity on same-text refreshes, including legacy null", () => { + const now = DateTime.makeUnsafe("2026-09-20T12:00:00Z"); + for (const previousErrorAt of [null, DateTime.makeUnsafe("2026-09-19T12:00:00Z")]) { + assert.strictEqual( + piLastErrorAt({ + previousError: "capacity exhausted", + previousErrorAt, + nextError: "capacity exhausted", + now, + }), + previousErrorAt, + ); + } + }); + + it("clears the occurrence and stamps changed or newly set errors", () => { + const now = DateTime.makeUnsafe("2026-09-20T12:00:00Z"); + const previousErrorAt = DateTime.makeUnsafe("2026-09-19T12:00:00Z"); + assert.isNull( + piLastErrorAt({ previousError: "capacity exhausted", previousErrorAt, nextError: null, now }), + ); + for (const previousError of [null, "different failure"]) { + assert.strictEqual( + piLastErrorAt({ + previousError, + previousErrorAt: null, + nextError: "capacity exhausted", + now, + }), + now, + ); + } + }); + + it.effect("emits a new occurrence for the same failure after the next turn clears it", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const occurrences: Array = []; + for (const runOrdinal of [1, 2]) { + yield* startTurn(runtime, providerThread, "default", [], "Hello pi", undefined, runOrdinal); + yield* fake.takeRequest("prompt"); + const running = yield* takeEvent( + (event) => + event.type === "provider_session.updated" && event.providerSession.status === "running", + ); + assert.equal(running.type, "provider_session.updated"); + if (running.type !== "provider_session.updated") return; + assert.isNull(running.providerSession.lastError); + assert.isNull(running.providerSession.lastErrorAt); + + yield* TestClock.adjust("1 second"); + const failedAt = yield* DateTime.now; + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "message_end", + message: { + role: "assistant", + content: [], + stopReason: "error", + errorMessage: "capacity exhausted", + }, + }); + yield* fake.emit({ type: "agent_settled" }); + const failed = yield* takeEvent( + (event) => + event.type === "provider_session.updated" && event.providerSession.status === "error", + ); + assert.equal(failed.type, "provider_session.updated"); + if (failed.type !== "provider_session.updated") return; + assert.equal(failed.providerSession.lastError, "capacity exhausted"); + assert.isNotNull(failed.providerSession.lastErrorAt); + assert.isDefined(failed.providerSession.lastErrorAt); + const occurrence = DateTime.toEpochMillis(failed.providerSession.lastErrorAt!); + assert.equal(occurrence, DateTime.toEpochMillis(failedAt)); + occurrences.push(occurrence); + yield* takeEvent((event) => event.type === "turn.terminal"); + } + assert.isAbove(occurrences[1]!, occurrences[0]!); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("retains the occurrence when transport cleanup repeats an unsolicited-work error", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent, eventsEnded } = yield* openRuntime(fake); + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ type: "agent_start" }); + const first = yield* takeEvent((event) => event.type === "provider_session.updated"); + assert.equal(first.type, "provider_session.updated"); + if (first.type !== "provider_session.updated") return; + assert.equal(first.providerSession.status, "error"); + assert.isNotNull(first.providerSession.lastErrorAt); + assert.isDefined(first.providerSession.lastErrorAt); + yield* eventsEnded; + const refreshed = yield* takeEvent((event) => event.type === "provider_session.updated"); + assert.equal(refreshed.type, "provider_session.updated"); + if (refreshed.type !== "provider_session.updated") return; + assert.equal(refreshed.providerSession.lastError, first.providerSession.lastError); + assert.deepEqual(refreshed.providerSession.lastErrorAt, first.providerSession.lastErrorAt); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + for (const transportFailure of [false, true]) { + it.effect( + `persists Pi occurrence through session-manager stream completion (transport failure: ${transportFailure})`, + () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const adapter = yield* makeAdapter(fake, ""); + const streamEnded = yield* Deferred.make(); + const finishPump = yield* Deferred.make(); + const released = yield* Deferred.make(); + const stores = Layer.merge(EventStore.layer, ProjectionStore.layer).pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const sink = EventSink.layer.pipe( + Layer.provide(Layer.merge(stores, SqlitePersistenceMemory)), + ); + const observedSink = Layer.effect( + EventSink.EventSinkV2, + Effect.gen(function* () { + const delegate = yield* EventSink.EventSinkV2; + return EventSink.EventSinkV2.of({ + ...delegate, + write: (input) => + delegate + .write(input) + .pipe( + Effect.tap(() => + input.events.some( + (event) => + event.type === "provider-session.updated" && + DateTime.toEpochMillis(event.payload.updatedAt) > 0, + ) + ? Deferred.succeed(released, undefined) + : Effect.void, + ), + ), + }); + }), + ).pipe(Layer.provide(sink)); + const registry = ProviderAdapterRegistry.makeSingleLayer({ + ...adapter, + openSession: (input) => + adapter.openSession(input).pipe( + Effect.map((runtime) => ({ + ...runtime, + events: Stream.concat( + runtime.events, + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(streamEnded, undefined); + yield* Deferred.await(finishPump); + if (transportFailure) + return yield* new ProviderAdapterEventStreamError({ + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + cause: "distinct transport failure", + }); + }), + ).pipe(Stream.drain), + ), + })), + ), + }); + const dependencies = Layer.mergeAll(stores, observedSink, IdAllocator.layer); + const managerLayer = ProviderSessionManager.layerWithOptions({ + configureMcp: false, + idleTimeoutMs: 60_000, + }).pipe( + Layer.provide( + Layer.mergeAll( + dependencies, + registry, + Layer.mock(McpSessionRegistry.McpSessionRegistry)({}), + ProviderEventIngestor.layer.pipe(Layer.provide(dependencies)), + ), + ), + ); + yield* Effect.gen(function* () { + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const eventSink = yield* EventSink.EventSinkV2; + const store = yield* ProjectionStore.ProjectionStoreV2; + const ids = yield* IdAllocator.IdAllocatorV2; + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: yield* ids.allocate.event({ threadId: THREAD_ID }), + type: "thread.created", + threadId: THREAD_ID, + occurredAt: now, + payload: yield* makeAppThread("default"), + }, + ], + }); + const runtime = yield* manager.open({ + threadId: THREAD_ID, + providerSessionId: SESSION_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ type: "agent_start" }); + yield* Deferred.await(streamEnded); + const before = (yield* store.getThreadProviderContext(THREAD_ID)).providerSessions.at( + -1, + )!; + assert.equal(before.status, "error"); + assert.include(before.lastError!, "invisible tool execution"); + assert.isNotNull(before.lastErrorAt); + assert.isDefined(before.lastErrorAt); + assert.equal(DateTime.toEpochMillis(before.lastErrorAt!), 0); + // Hold only the manager's observation of the already-ended adapter stream. + yield* TestClock.adjust("1 second"); + yield* Deferred.succeed(finishPump, undefined); + yield* Deferred.await(released); + const after = (yield* store.getThreadProviderContext(THREAD_ID)).providerSessions.at( + -1, + )!; + assert.equal(after.status, "error"); + if (transportFailure) { + assert.include(after.lastError!, "distinct transport failure"); + assert.equal(DateTime.toEpochMillis(after.lastErrorAt!), 1000); + } else { + assert.equal(after.lastError, before.lastError); + assert.deepEqual(after.lastErrorAt, before.lastErrorAt); + } + }).pipe(Effect.provide(Layer.merge(dependencies, managerLayer))); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + } + it.effect("stops provider-initiated work that has no T3 turn owner", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 88489a1f9040..1a3debeba56b 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -95,6 +95,18 @@ import { import { PI_FILE_CHANGE_TOOLS } from "./piT3McpExtensionSource.ts"; export const PI_PROVIDER = ProviderDriverKind.make("pi"); +export function piLastErrorAt(input: { + readonly previousError: string | null; + readonly previousErrorAt: DateTime.Utc | null; + readonly nextError: string | null; + readonly now: DateTime.Utc; +}): DateTime.Utc | null { + if (input.nextError === null) return null; + // A status refresh is not a new occurrence, even for untimestamped legacy errors. + if (input.nextError === input.previousError) return input.previousErrorAt; + return input.now; +} + const PI_DRIVER_KIND = PI_PROVIDER; const PI_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(PI_DRIVER_KIND); const DEFAULT_PI_SETTINGS = Schema.decodeSync(PiSettings)({}); @@ -532,7 +544,13 @@ export function makePiAdapterV2( ) => Effect.gen(function* () { const updatedAt = yield* DateTime.now; - sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; + const lastErrorAt = piLastErrorAt({ + previousError: sessionEntity.lastError, + previousErrorAt: sessionEntity.lastErrorAt ?? null, + nextError: lastError, + now: updatedAt, + }); + sessionEntity = { ...sessionEntity, status, lastError, lastErrorAt, updatedAt }; yield* emit({ type: "provider_session.updated", driver: PI_PROVIDER, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 9efefed80483..95d9ddc9ea37 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2059,6 +2059,7 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { const assertSummary = Effect.fnUntraced(function* ( lastError: string | null, lastErrorClass: string | null, + lastErrorAt: string | null = lastError === null ? null : DateTime.formatIso(now), ) { const projection = yield* store.getThreadProjection(threadId); const memoryShell = ProjectionStore.threadShellFromProjection(projection); @@ -2066,6 +2067,7 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { const sqlShell = shells.threads.find((row) => row.id === threadId)!; for (const shell of [memoryShell, sqlShell]) { assert.equal(shell.lastError, lastError); + assert.equal(shell.lastErrorAt, lastErrorAt); assert.equal(shell.lastErrorClass, lastErrorClass); assert.equal( shell.usageLimitResetAt, @@ -2306,9 +2308,22 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { type: "provider-session.updated", threadId, occurredAt: now, + payload: { + ...session, + status: "error", + lastError: "Provider process exited.", + lastErrorAt: DateTime.makeUnsafe("2026-09-20T02:00:00.000Z"), + }, + }); + yield* assertSummary("Provider process exited.", null, "2026-09-20T02:00:00.000Z"); + yield* store.apply({ + id: EventId.make("event:limit-shell:legacy-error"), + type: "provider-session.updated", + threadId, + occurredAt: now, payload: { ...session, status: "error", lastError: "Provider process exited." }, }); - yield* assertSummary("Provider process exited.", null); + yield* assertSummary("Provider process exited.", null, null); yield* store.apply({ id: EventId.make("event:limit-shell:session-recovered"), type: "provider-session.updated", diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 7b99089b8b72..ddd2ed4c9365 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1,5 +1,6 @@ import { - latestRootProviderFailure, + latestRootProviderFailureItem, + providerFailureOccurredAt, threadErrorSummary, usageLimitRunPresentedAsLatest, } from "@t3tools/shared/orchestrationV2ThreadError"; @@ -897,7 +898,7 @@ type ShellThreadRow = { readonly active_run_id: string | null; readonly activity_run_status: string | null; readonly activity_run_started_at: string | null; - readonly last_error: string | null; + readonly error_state_json: string | null; readonly terminal_failure_payload_json: string | null; readonly blocking_run_id: string | null; readonly blocking_run_requested_at: string | null; @@ -1334,6 +1335,7 @@ export function threadShellFromProjection( activeProviderThreadId: projection.thread.activeProviderThreadId, runs: projection.runs, }); + const failureItem = latestRootProviderFailureItem(latestRun, projection.turnItems); return { createdBy: projection.thread.createdBy, creationSource: projection.thread.creationSource, @@ -1370,10 +1372,13 @@ export function threadShellFromProjection( activityRunStatus: activityRun?.status ?? null, activityRunStartedAt: activityRun?.startedAt ?? activityRun?.requestedAt ?? null, status: latestRun?.status ?? "idle", - ...threadErrorSummary( - latestRootProviderFailure(latestRun, projection.turnItems), - providerSession?.lastError ?? null, - ), + ...threadErrorSummary(failureItem?.failure ?? null, providerSession?.lastError ?? null, { + sessionErrorAt: + providerSession?.lastErrorAt == null + ? null + : DateTime.formatIso(providerSession.lastErrorAt), + failureAt: providerFailureOccurredAt(failureItem), + }), pendingRuntimeRequest: pendingRuntimeRequest === null ? null @@ -1455,6 +1460,15 @@ function isActivityRunForShell( return isInterruptibleRunForShell(run) || run.status === "waiting"; } +const decodeShellErrorState = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + lastError: Schema.NullOr(Schema.String), + lastErrorAt: Schema.NullOr(Schema.String), + }), + ), +); + type ShellThreadState = { readonly thread: OrchestrationV2ThreadProjection["thread"]; readonly latestRunId: RunId | null; @@ -1466,6 +1480,7 @@ type ShellThreadState = { readonly activityRunStatus: ShellActivityRunStatus | null; readonly activityRunStartedAt: DateTime.Utc | null; readonly lastError: string | null; + readonly lastErrorAt: string | null; readonly lastErrorClass: OrchestrationV2ThreadShell["lastErrorClass"]; readonly usageLimitResetAt: OrchestrationV2ThreadShell["usageLimitResetAt"]; readonly pendingRuntimeRequest: OrchestrationV2ThreadProjection["runtimeRequests"][number] | null; @@ -1603,6 +1618,7 @@ function shellFromState(input: { activityRunStartedAt: input.state.activityRunStartedAt, status: input.state.latestRunStatus, lastError: input.state.lastError, + lastErrorAt: input.state.lastErrorAt, lastErrorClass: input.state.lastErrorClass, usageLimitResetAt: input.state.usageLimitResetAt, pendingRuntimeRequest: @@ -4819,7 +4835,10 @@ export const layer: Layer.Layer = LIMIT 1 ) AS activity_run_started_at, ( - SELECT json_extract(session.payload_json, '$.lastError') + SELECT json_object( + 'lastError', json_extract(session.payload_json, '$.lastError'), + 'lastErrorAt', json_extract(session.payload_json, '$.lastErrorAt') + ) FROM orchestration_v2_projection_provider_sessions session INNER JOIN orchestration_v2_projection_provider_session_bindings binding ON binding.provider_session_id = session.provider_session_id @@ -4827,7 +4846,7 @@ export const layer: Layer.Layer = AND session.provider_instance_id = t.provider_instance_id ORDER BY session.updated_at DESC, session.provider_session_id DESC LIMIT 1 - ) AS last_error, + ) AS error_state_json, ( SELECT item.payload_json FROM orchestration_v2_projection_turn_items item @@ -5196,6 +5215,8 @@ export const layer: Layer.Layer = row.pending_request_payload_json === null ? null : yield* decodeRuntimeRequestPayload(row.pending_request_payload_json); + const sessionError = + row.error_state_json === null ? null : yield* decodeShellErrorState(row.error_state_json); let terminalFailureItem = row.terminal_failure_payload_json === null ? null @@ -5221,7 +5242,7 @@ export const layer: Layer.Layer = const blocksQueue = threadErrorSummary( blockingFailure?.type === "error" ? blockingFailure.failure : null, - row.last_error, + sessionError?.lastError ?? null, ).lastErrorClass === "usage_limit"; if (blocksQueue && blockingFailure !== null) { terminalFailureItem = blockingFailure; @@ -5278,7 +5299,13 @@ export const layer: Layer.Layer = : null, ...threadErrorSummary( terminalFailureItem?.type === "error" ? terminalFailureItem.failure : null, - row.last_error, + sessionError?.lastError ?? null, + { + sessionErrorAt: sessionError?.lastErrorAt ?? null, + failureAt: providerFailureOccurredAt( + terminalFailureItem?.type === "error" ? terminalFailureItem : null, + ), + }, ), pendingRuntimeRequest, latestUserMessageAt: diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 8fcf5d88c2e1..c9deaf672cfa 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -13,7 +13,7 @@ import { ProjectId, ProviderDriverKind, ProviderInstanceId, - type ProviderSessionId, + ProviderSessionId, ProviderTurnId, ThreadId, } from "@t3tools/contracts"; @@ -24,6 +24,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -369,6 +370,7 @@ function makeTestLayer(input: { readonly initialProviderItemIdentityVersion?: 2; }) => Effect.Effect; readonly failReleaseEventWrites?: boolean; + readonly failProjectionReads?: Ref.Ref; readonly hasPendingBackgroundWork?: Effect.Effect; readonly hasBufferedOutputForThread?: ( thread: OrchestrationV2ProviderThread, @@ -401,6 +403,32 @@ function makeTestLayer(input: { ...(input.beforeUnload === undefined ? {} : { beforeUnload: input.beforeUnload }), }), ); + const releaseStoresLayer = + input.failProjectionReads === undefined + ? TestStoresLayer + : Layer.effect( + ProjectionStore.ProjectionStoreV2, + Effect.gen(function* () { + const delegate = yield* ProjectionStore.ProjectionStoreV2; + const failReads = input.failProjectionReads!; + return ProjectionStore.ProjectionStoreV2.of({ + ...delegate, + getThreadProviderContext: (threadId, targetInstanceId) => + Ref.get(failReads).pipe( + Effect.flatMap((fail) => + fail + ? Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId, + cause: "private error detail", + }), + ) + : delegate.getThreadProviderContext(threadId, targetInstanceId), + ), + ), + }); + }), + ).pipe(Layer.provide(TestStoresLayer)); const providerEventIngestorTestLayer = ProviderEventIngestor.layer.pipe( Layer.provide(Layer.mergeAll(configuredEventSinkLayer, IdAllocator.layer, TestStoresLayer)), ); @@ -420,7 +448,7 @@ function makeTestLayer(input: { IdAllocator.layer, providerEventIngestorTestLayer, TestMcpRegistryLayer, - TestStoresLayer, + releaseStoresLayer, ...(input.serverSettingsLayer === undefined ? [] : [input.serverSettingsLayer]), ...(input.projectServiceLayer === undefined ? [] : [input.projectServiceLayer]), ), @@ -2405,6 +2433,169 @@ it.effect( }), ); +it("preserves error occurrences on teardown and stamps a new runtime failure", () => { + const now = DateTime.makeUnsafe("2026-09-20T01:00:00Z"); + const later = DateTime.makeUnsafe("2026-09-20T02:00:00Z"); + const session = { + ...makeProviderSession({ + providerSessionId: ProviderSessionId.make("session:occurrence"), + now, + }), + lastError: "Failed", + lastErrorAt: now, + }; + for (const reason of ["idle_timeout", "manual_shutdown", "server_shutdown"] as const) { + const released = ProviderSessionManager.releasedProviderSession({ + session, + now: later, + reason, + }); + assert.equal(released.lastError, "Failed"); + assert.deepStrictEqual(released.lastErrorAt, now); + } + const failed = ProviderSessionManager.releasedProviderSession({ + session, + now: later, + reason: "runtime_error", + detail: "Failed", + }); + assert.deepStrictEqual(failed.lastErrorAt, later); +}); + +it("only preserves a Pi terminal error on clean stream end", () => { + const now = DateTime.makeUnsafe("2026-09-20T01:00:00Z"); + const later = DateTime.makeUnsafe("2026-09-20T02:00:00Z"); + for (const driver of ["opencode", "pi"] as const) { + for (const status of ["error", "ready"] as const) { + const session = { + ...makeProviderSession({ + providerSessionId: ProviderSessionId.make("session:stream-end"), + now, + }), + driver: ProviderDriverKind.make(driver), + status, + lastError: "Earlier turn failed", + lastErrorAt: null, + }; + const released = ProviderSessionManager.releasedProviderSession({ + session, + now: later, + reason: "runtime_error", + runtimeErrorCause: "stream_end", + detail: "Provider event stream ended.", + }); + const preserve = driver === "pi" && status === "error"; + assert.equal( + released.lastError, + preserve ? "Earlier turn failed" : "Provider event stream ended.", + ); + assert.deepStrictEqual(released.lastErrorAt, preserve ? null : later); + } + } +}); + +it.effect("ProviderSessionManagerV2 warns on projection read failure and still releases", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const failProjectionReads = yield* Ref.make(false); + const messages: Array<{ level: string; message: unknown }> = []; + const logger = Logger.make(({ logLevel, message }) => { + messages.push({ level: logLevel, message }); + }); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("release-read-failure"); + const providerSessionId = ProviderSessionId.make("release-read-failure-session"); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + yield* Ref.set(failProjectionReads, true); + yield* manager.release({ providerSessionId, reason: "idle_timeout" }); + assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); + assert.equal((yield* Ref.get(state)).closeCount, 1); + assert.deepStrictEqual( + messages.filter((entry) => entry.level === "Warn"), + [ + { + level: "Warn", + message: [ + "orchestration-v2.driver-session.release-projection-read-failed", + { + threadId, + providerSessionId, + reason: "idle_timeout", + }, + ], + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer({ state, idleTimeoutMs: 60_000, failProjectionReads }).pipe( + Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })), + ), + ), + ); + }), +); + +it.effect("ProviderSessionManagerV2 preserves the latest projected error during idle release", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const projectId = yield* idAllocator.allocate.project({ fixtureName: "error-preservation" }); + const threadId = yield* idAllocator.allocate.thread({ + fixtureName: "error-preservation", + projectId, + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const runtime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ threadId, providerSessionId }), + type: "provider-session.updated", + threadId, + driver: CODEX_DRIVER, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...runtime.providerSession, + status: "error", + lastError: "Projected error", + lastErrorAt: now, + }, + }, + ], + }); + yield* manager.release({ providerSessionId, reason: "idle_timeout" }); + const after = yield* projectionStore.getThreadProviderContext(threadId); + assert.equal(after.providerSessions.at(-1)?.lastError, "Projected error"); + assert.deepStrictEqual(after.providerSessions.at(-1)?.lastErrorAt, now); + }).pipe(Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000 }))); + }), +); + it.effect("ProviderSessionManagerV2 uses the same release path for runtime failures", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 2f2789267ee4..18d52615e9a2 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -257,6 +257,33 @@ function releaseStatusFor( return reason === "runtime_error" ? "error" : "stopped"; } +export function releasedProviderSession(input: { + readonly reason: ProviderSessionReleaseReason; + readonly session: OrchestrationV2ProviderSession; + readonly now: DateTime.Utc; + readonly detail?: string | undefined; + readonly runtimeErrorCause?: "stream_end" | undefined; +}): OrchestrationV2ProviderSession { + if (input.reason === "runtime_error") { + if ( + input.runtimeErrorCause === "stream_end" && + input.session.driver === "pi" && + input.session.status === "error" && + input.session.lastError !== null + ) { + return { ...input.session, updatedAt: input.now }; + } + return { + ...input.session, + status: "error", + updatedAt: input.now, + lastError: input.detail ?? "Provider runtime failed.", + lastErrorAt: input.now, + }; + } + return { ...input.session, status: releaseStatusFor(input.reason), updatedAt: input.now }; +} + function releasedRuntimeRequestStatusFor( reason: ProviderSessionReleaseReason, ): OrchestrationV2RuntimeRequest["status"] { @@ -610,18 +637,39 @@ export const layerWithOptions = ( readonly entry: LiveSessionEntry; readonly reason: ProviderSessionReleaseReason; readonly detail?: string; + readonly runtimeErrorCause?: "stream_end"; }) => Effect.gen(function* () { const now = yield* DateTime.now; - const payload: OrchestrationV2ProviderSession = { - ...input.entry.runtime.providerSession, - status: releaseStatusFor(input.reason), - updatedAt: now, - lastError: - input.reason === "runtime_error" - ? (input.detail ?? "Provider runtime failed.") - : null, - }; + let session = input.entry.runtime.providerSession; + for (const threadId of input.entry.attachedThreadIds) { + const context = yield* projectionStore.getThreadProviderContext(threadId).pipe( + Effect.catch(() => + Effect.logWarning( + "orchestration-v2.driver-session.release-projection-read-failed", + { + threadId, + providerSessionId: session.id, + reason: input.reason, + }, + ).pipe(Effect.as(null)), + ), + ); + const projected = context?.providerSessions.findLast( + (candidate) => candidate.id === session.id, + ); + if (projected !== undefined) { + session = projected; + break; + } + } + const payload = releasedProviderSession({ + session, + reason: input.reason, + now, + detail: input.detail, + runtimeErrorCause: input.runtimeErrorCause, + }); yield* writeProviderSessionEvents({ runtime: input.entry.runtime, threadIds: input.entry.attachedThreadIds, @@ -737,6 +785,7 @@ export const layerWithOptions = ( readonly providerSessionId: ProviderSessionId; readonly reason: ProviderSessionReleaseReason; readonly detail?: string; + readonly runtimeErrorCause?: "stream_end"; readonly cancelIdleFiber?: boolean; readonly onlyIfIdleGeneration?: number; readonly gracefulSubscribers?: boolean; @@ -822,6 +871,9 @@ export const layerWithOptions = ( yield* writeReleasedSessionEvents({ entry, reason: input.reason, + ...(input.runtimeErrorCause === undefined + ? {} + : { runtimeErrorCause: input.runtimeErrorCause }), ...(input.detail === undefined ? {} : { detail: input.detail }), }); yield* writeReleasedRuntimeRequestEvents({ @@ -1938,6 +1990,7 @@ export const layerWithOptions = ( yield* releaseEntry({ providerSessionId: entry.runtime.providerSessionId, reason: "runtime_error", + ...(Exit.isSuccess(exit) ? { runtimeErrorCause: "stream_end" as const } : {}), detail: Cause.pretty(cause), }).pipe(Effect.ignore); }), diff --git a/apps/server/src/orchestration-v2/testkit/ErrorOccurrence.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ErrorOccurrence.integration.test.ts new file mode 100644 index 000000000000..2fe06adce952 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/ErrorOccurrence.integration.test.ts @@ -0,0 +1,88 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; +import { layer as idAllocatorLayer } from "../IdAllocator.ts"; +import { ProjectionStoreV2, threadShellFromProjection } from "../ProjectionStore.ts"; +import { ProviderSessionManagerV2 } from "../ProviderSessionManager.ts"; +import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; +import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; +import { CLAUDE_RESULT_IS_ERROR_PROMPT, materializeFixtureInput } from "./fixtures/shared.ts"; +import { runOrchestratorV2Scenario } from "./OrchestratorScenario.ts"; +import { makeOrchestratorV2ProviderReplayLayer } from "./ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./ReplayFixtureWorkspace.ts"; +import { readProviderReplayTranscript } from "./ReplayTranscriptNdjson.ts"; + +it.effect("preserves a replayed root failure occurrence through session release", () => + Effect.gen(function* () { + const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find( + (entry) => entry.name === "claude_result_is_error", + )!; + const provider = fixture.providers.find((entry) => entry.driver === "claudeAgent")!; + const raw = yield* readProviderReplayTranscript(provider.transcriptFile).pipe( + Effect.provide(NodeServices.layer), + ); + const end = raw.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "result:1_is_error", + ); + assert.isAtLeast(end, 0); + const transcript = yield* ClaudeOrchestratorReplayHarness.decodeTranscript({ + ...raw, + entries: raw.entries.slice(0, end + 1), + }); + const materialized = yield* materializeFixtureInput({ + scenario: "error-occurrence", + fixtureInput: { steps: [{ type: "message", text: CLAUDE_RESULT_IS_ERROR_PROMPT }] }, + driver: provider.driver, + modelSelection: provider.modelSelection, + }).pipe(Effect.provide(idAllocatorLayer), provideDeterministicTestRuntime); + const scenario = { + name: "error-occurrence", + transcript, + ...materialized, + runtimePolicyOverride: { + ...provider.runtimePolicyOverride, + cwd: yield* checkpointWorkspace("error-occurrence"), + }, + }; + yield* Effect.gen(function* () { + const result = yield* runOrchestratorV2Scenario(scenario); + const threadId = materialized.projectionThreadIds[0]!; + const projection = result.projections.get(threadId)!; + assert.equal(projection.runs.at(-1)?.status, "failed"); + const item = projection.turnItems.find((item) => item.type === "error"); + assert.isDefined(item); + if (item?.type !== "error") throw new Error("Missing replayed error"); + const occurrenceAt = item.completedAt ?? item.startedAt; + assert.isNotNull(occurrenceAt); + if (occurrenceAt === null) throw new Error("Missing failure occurrence"); + const expectedAt = DateTime.formatIso(occurrenceAt); + const before = threadShellFromProjection(projection); + assert.equal(before.lastError, item.failure.message); + assert.equal(before.lastErrorAt, expectedAt); + const manager = yield* ProviderSessionManagerV2; + const store = yield* ProjectionStoreV2; + const session = projection.providerSessions.at(-1)!; + assert.isDefined(session); + yield* TestClock.adjust("1 second"); + yield* manager.release({ providerSessionId: session.id, reason: "idle_timeout" }); + const after = yield* store.getThreadProjection(threadId); + assert.equal(after.providerSessions.at(-1)?.status, "stopped"); + const sqlShell = (yield* store.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + )!; + for (const shell of [threadShellFromProjection(after), sqlShell]) { + assert.equal(shell.lastError, before.lastError); + assert.equal(shell.lastErrorAt, expectedAt); + } + }).pipe( + Effect.provide( + makeOrchestratorV2ProviderReplayLayer(scenario, ClaudeOrchestratorReplayHarness), + ), + provideDeterministicTestRuntime, + ); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index caeab811ace4..5c284e60893e 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -235,7 +235,11 @@ export function makeOrchestratorV2ProviderReplayLayer< readonly replayGate?: ProviderReplayGate; } = {}, ): Layer.Layer< - Orchestrator.OrchestratorV2 | EffectWorker.OrchestrationEffectWorkerV2 | EventSink.EventSinkV2, + | Orchestrator.OrchestratorV2 + | EffectWorker.OrchestrationEffectWorkerV2 + | EventSink.EventSinkV2 + | ProjectionStore.ProjectionStoreV2 + | ProviderSessionManager.ProviderSessionManagerV2, Error | MigrationError | PlatformError.PlatformError | SqlError > { const registryLayer = harness.makeProviderAdapterRegistryLayer( @@ -266,6 +270,7 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( | Orchestrator.OrchestratorV2 | EffectWorker.OrchestrationEffectWorkerV2 | EventSink.EventSinkV2 + | ProjectionStore.ProjectionStoreV2 | ProviderSessionManager.ProviderSessionManagerV2, Error | MigrationError | PlatformError.PlatformError | SqlError > { @@ -463,6 +468,7 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( eventSinkProvided, continuationWorkerProvided, providerSessionManagerProvided, + storesLayer, ).pipe(Layer.provide(worktreeRepairDependenciesTestLayer), Layer.provide(NodeServices.layer)); // Build the daemon from the exact worker instance exposed alongside the diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 009d3baf6ded..e603bbda83ee 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -440,13 +440,8 @@ import { ProviderStatusBanner, shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; -import { - dismissThreadErrorBannerForSession, - getThreadErrorBannerKey, - isThreadErrorBannerDismissedForSession, - shouldShowThreadErrorBanner, - ThreadErrorBanner, -} from "./chat/ThreadErrorBanner"; +import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; +import { useThreadErrorState } from "./chat/useThreadErrorState"; import { QueuedRunsControl, type QueuedRunsControlHandle, @@ -526,7 +521,6 @@ import { releaseDraftAttachments, startAttachmentUpload, } from "../lib/attachmentUploadQueue"; -import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; @@ -1465,14 +1459,6 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane ); }); -// Errors surface through two maps (draft-keyed and thread-keyed) whose entries -// can race around promotion, so each write carries its time to let the latest -// one win when they collide. -type LocalThreadErrorEntry = { - readonly message: string | null; - readonly at: number; -}; - function chatActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } @@ -1812,12 +1798,6 @@ export default function ChatView(props: ChatViewProps) { ); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; - const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< - Record - >({}); - const [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState< - Record - >({}); const [isConnecting, _setIsConnecting] = useState(false); const isRevertingCheckpoint = useComposerDraftStore((store) => store.rewindingThreadKeys.has(routeThreadKey), @@ -1965,45 +1945,6 @@ export default function ChatView(props: ChatViewProps) { ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const fallbackDraftProject = useProject(fallbackDraftProjectRef); - const localDraftError = serverThread - ? null - : ((draftId ? localDraftErrorsByDraftId[draftId]?.message : null) ?? null); - const localServerError = localServerErrorsByThreadKey[routeThreadKey]?.message ?? null; - // Draft errors are keyed by draftId while server errors are keyed by thread - // key, so a pending draft entry must migrate when the server thread loads or - // a failed send would silently disappear on promotion. When both keys hold - // an entry, the most recent write wins. - useEffect(() => { - if (!serverThread || !draftId) { - return; - } - const pendingDraftEntry = localDraftErrorsByDraftId[draftId]; - if (pendingDraftEntry === undefined) { - return; - } - setLocalDraftErrorsByDraftId((existing) => { - if (existing[draftId] === undefined) { - return existing; - } - const next = { ...existing }; - delete next[draftId]; - return next; - }); - setLocalServerErrorsByThreadKey((existing) => { - const currentEntry = existing[routeThreadKey]; - if ( - currentEntry !== undefined && - (currentEntry.at > pendingDraftEntry.at || - currentEntry.message === pendingDraftEntry.message) - ) { - return existing; - } - return { - ...existing, - [routeThreadKey]: pendingDraftEntry, - }; - }); - }, [draftId, localDraftErrorsByDraftId, routeThreadKey, serverThread]); const localDraftThread = useMemo( () => draftThread @@ -2086,27 +2027,18 @@ export default function ChatView(props: ChatViewProps) { }, [parentSubagentThread?.title, parentSubagentThreadRef], ); - const threadError = isServerThread - ? (localServerError ?? serverRuntime?.lastError ?? null) - : localDraftError; - // Dismissals can only mask the shown error, never clear it: a server thread - // keeps its error in session.lastError, so clearing the local shadow would - // just fall through to the persisted one. Mask the current error until a - // different error arrives, mirroring the provider status banner. - const threadErrorBannerKey = getThreadErrorBannerKey(routeThreadKey, threadError); - const visibleThreadError = shouldShowThreadErrorBanner( - routeThreadKey, - threadError, - isThreadErrorBannerDismissedForSession(threadErrorBannerKey), - ) - ? threadError - : null; - // Dismissing only mutates the session-scoped mask set, which does not - // trigger a render on its own; setThreadError(null) can also bail when the - // local shadow is already empty and the banner is driven purely by - // session.lastError. Bump a tick so the banner hides immediately. Mirrors - // the branch mismatch banner. - const [, setThreadErrorBannerDismissTick] = useState(0); + const { + error: visibleThreadError, + currentError: threadError, + errorClass: threadErrorClass, + dismiss: dismissThreadError, + setError: setLocalThreadError, + } = useThreadErrorState({ + threadKey: routeThreadKey, + draftId, + isServerThread, + runtime: isServerThread ? (serverRuntime ?? null) : null, + }); const defaultRuntimeMode = resolveProjectSettings(settings, activeThread?.projectId ?? null) .settings.defaultRuntimeMode; // Implicit drafts follow their current project/environment, including retargets. @@ -4253,37 +4185,19 @@ export default function ChatView(props: ChatViewProps) { const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; - const nextError = sanitizeThreadErrorMessage(error); - const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; if ( serverThread && targetThreadId === routeThreadRef.threadId && serverThread.environmentId === routeThreadRef.environmentId && serverThread.id === targetThreadId ) { - setLocalServerErrorsByThreadKey((existing) => { - if ((existing[routeThreadKey]?.message ?? null) === nextError) { - return existing; - } - return { - ...existing, - [routeThreadKey]: nextEntry, - }; - }); + setLocalThreadError(routeThreadKey, true, error); return; } const localDraftErrorKey = draftId ?? targetThreadId; - setLocalDraftErrorsByDraftId((existing) => { - if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { - return existing; - } - return { - ...existing, - [localDraftErrorKey]: nextEntry, - }; - }); + setLocalThreadError(localDraftErrorKey, false, error); }, - [draftId, routeThreadKey, routeThreadRef, serverThread], + [draftId, routeThreadKey, routeThreadRef, serverThread, setLocalThreadError], ); const focusComposer = useCallback(() => { @@ -10601,16 +10515,8 @@ export default function ChatView(props: ChatViewProps) { /> { - setThreadError(activeThread.id, null); - dismissThreadErrorBannerForSession(threadErrorBannerKey); - setThreadErrorBannerDismissTick((tick) => tick + 1); - }} + errorClass={threadErrorClass} + onDismiss={dismissThreadError} /> {/* Messages Wrapper */} diff --git a/apps/web/src/components/chat/ThreadErrorBanner.test.tsx b/apps/web/src/components/chat/ThreadErrorBanner.test.tsx index a3dbb27515ae..3677c9ece156 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.test.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.test.tsx @@ -6,6 +6,7 @@ import { getThreadErrorBannerKey, isThreadErrorBannerDismissedForSession, shouldShowThreadErrorBanner, + resolveThreadErrorBanner, ThreadErrorBanner, } from "./ThreadErrorBanner"; @@ -66,6 +67,65 @@ describe("ThreadErrorBanner", () => { ).toBe(false); }); + it("distinguishes same-text occurrences and local errors from hidden runtime failures", () => { + const thread = "env:occurrence"; + const first = getThreadErrorBannerKey(thread, "Failed", "2026-09-20T01:00:00Z"); + dismissThreadErrorBannerForSession(first); + expect( + isThreadErrorBannerDismissedForSession( + getThreadErrorBannerKey(thread, "Failed", "2026-09-20T01:00:00Z"), + ), + ).toBe(true); + expect( + isThreadErrorBannerDismissedForSession( + getThreadErrorBannerKey(thread, "Failed", "2026-09-20T02:00:00Z"), + ), + ).toBe(false); + const local = getThreadErrorBannerKey(thread, "Failed", 12, "local"); + dismissThreadErrorBannerForSession(local); + expect( + isThreadErrorBannerDismissedForSession( + getThreadErrorBannerKey(thread, "Failed", 13, "local"), + ), + ).toBe(false); + expect( + isThreadErrorBannerDismissedForSession( + getThreadErrorBannerKey(thread, "Failed", 12, "runtime"), + ), + ).toBe(false); + }); + + it("keeps the hidden runtime occurrence masked but shows a later provider occurrence", () => { + const input = { + threadKey: "env:precedence", + localError: "Failed", + localErrorAt: 1, + runtime: { lastError: "Failed", lastErrorAt: "2026-09-20T01:00:00Z" }, + }; + const local = resolveThreadErrorBanner(input); + dismissThreadErrorBannerForSession( + local.key, + getThreadErrorBannerKey(input.threadKey, input.runtime.lastError, input.runtime.lastErrorAt), + ); + expect(resolveThreadErrorBanner(input)).toEqual(local); + const laterRuntime = resolveThreadErrorBanner({ + ...input, + runtime: { lastError: "Provider failed", lastErrorAt: "2026-09-20T02:00:00Z" }, + }); + expect(laterRuntime.error).toBe("Provider failed"); + expect(isThreadErrorBannerDismissedForSession(laterRuntime.key)).toBe(false); + const laterLocal = resolveThreadErrorBanner({ ...input, localErrorAt: 2 }); + expect(isThreadErrorBannerDismissedForSession(laterLocal.key)).toBe(false); + const runtime = resolveThreadErrorBanner({ ...input, localError: null }); + expect(isThreadErrorBannerDismissedForSession(runtime.key)).toBe(false); + const unknownAt = resolveThreadErrorBanner({ + ...input, + localError: null, + runtime: { lastError: "Failed", lastErrorAt: null }, + }); + expect(unknownAt.key).toBe(getThreadErrorBannerKey(input.threadKey, "Failed")); + }); + it("never shows a null error", () => { expect(shouldShowThreadErrorBanner("env:thread-e", null, false)).toBe(false); }); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index 555a76972db4..1f98c86ecd9b 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -7,8 +7,51 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { OpenAI } from "../Icons"; import { ChatGptUsageButton } from "../settings/ChatGptUsageButton"; -export function getThreadErrorBannerKey(threadKey: string, error: string | null): string | null { - return error === null ? null : `${threadKey}\u0000${error}`; +export function getThreadErrorBannerKey( + threadKey: string, + error: string | null, + occurrence: string | number | null = null, + source: "local" | "runtime" = "runtime", +): string | null { + return error === null + ? null + : `${threadKey}\u0000${source}\u0000${occurrence ?? ""}\u0000${error}`; +} + +export function resolveThreadErrorBanner(input: { + threadKey: string; + localError: string | null; + localErrorAt: number | null; + runtime: { readonly lastError: string | null; readonly lastErrorAt?: string | null } | null; +}) { + const localKey = getThreadErrorBannerKey( + input.threadKey, + input.localError, + input.localErrorAt, + "local", + ); + const runtimeKey = getThreadErrorBannerKey( + input.threadKey, + input.runtime?.lastError ?? null, + input.runtime?.lastErrorAt ?? null, + ); + const isLocal = + input.localError !== null && + !( + localKey !== null && + sessionDismissedThreadErrorBannerKeys.has(localKey) && + sessionDismissedThreadErrorBannerKeys.get(localKey) !== runtimeKey + ); + const error = isLocal ? input.localError : (input.runtime?.lastError ?? null); + return { + error, + key: getThreadErrorBannerKey( + input.threadKey, + error, + isLocal ? input.localErrorAt : (input.runtime?.lastErrorAt ?? null), + isLocal ? "local" : "runtime", + ), + }; } export function shouldShowThreadErrorBanner( @@ -21,14 +64,16 @@ export function shouldShowThreadErrorBanner( // Session-scoped (module-level so it survives ChatView remounts, e.g. route // changes between threads). Mirrors the branch-mismatch banner: a dismissal -// is remembered per thread key plus message, so navigating away to a thread -// with no error cannot resurrect the banner, while a different error message -// on the same thread still appears. -const sessionDismissedThreadErrorBannerKeys = new Set(); +// is remembered per thread and visible error occurrence. Navigation cannot +// resurrect that occurrence, but a later failure with the same message appears. +const sessionDismissedThreadErrorBannerKeys = new Map(); -export function dismissThreadErrorBannerForSession(bannerKey: string | null): void { +export function dismissThreadErrorBannerForSession( + bannerKey: string | null, + runtimeKey: string | null = null, +): void { if (bannerKey !== null) { - sessionDismissedThreadErrorBannerKeys.add(bannerKey); + sessionDismissedThreadErrorBannerKeys.set(bannerKey, runtimeKey); } } diff --git a/apps/web/src/components/chat/useThreadErrorState.test.tsx b/apps/web/src/components/chat/useThreadErrorState.test.tsx new file mode 100644 index 000000000000..19157334e30e --- /dev/null +++ b/apps/web/src/components/chat/useThreadErrorState.test.tsx @@ -0,0 +1,96 @@ +import { act, useLayoutEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { useThreadErrorState } from "./useThreadErrorState"; + +let renderer: ReactTestRenderer; +let value: ReturnType; +let input: Parameters[0]; +let serial = 0; +function Probe() { + const state = useThreadErrorState(input); + useLayoutEffect(() => { + value = state; + }); + return
{state.error}
; +} +function render() { + act(() => renderer.update()); +} +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.spyOn(Date, "now").mockReturnValue(100); + input = { + threadKey: `env:hook-${++serial}`, + draftId: "draft", + isServerThread: true, + runtime: { + lastError: "Durable failure", + lastErrorAt: "2026-09-25T00:00:00Z", + lastErrorClass: "usage_limit", + }, + }; + act(() => { + renderer = create(); + }); +}); +afterEach(() => { + act(() => renderer.unmount()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +it("dismisses a local failure without revealing the unchanged durable failure", () => { + act(() => value.setError(input.threadKey, true, "Save failed")); + expect(value.error).toBe("Save failed"); + act(() => value.dismiss()); + expect(value.error).toBeNull(); + render(); + expect(value.error).toBeNull(); + input.runtime = { ...input.runtime!, lastErrorAt: "2026-09-25T00:01:00Z" }; + render(); + expect(value.error).toBe("Durable failure"); + expect(value.errorClass).toBe("usage_limit"); +}); +it("reappears for an identical local write even in the same clock tick", () => { + act(() => value.setError(input.threadKey, true, "Save failed")); + act(() => value.dismiss()); + expect(value.error).toBeNull(); + act(() => value.setError(input.threadKey, true, "Save failed")); + expect(value.error).toBe("Save failed"); + expect(value.errorClass).toBeNull(); +}); +it("keeps a newer undismissed local failure visible", () => { + act(() => value.setError(input.threadKey, true, "Save failed")); + act(() => value.dismiss()); + input.runtime = { lastError: "Provider failure", lastErrorAt: "2026-09-25T00:01:00Z" }; + render(); + expect(value.error).toBe("Provider failure"); + act(() => value.setError(input.threadKey, true, "Save failed")); + expect(value.error).toBe("Save failed"); +}); +it("dismisses runtime errors immediately and remembers them across remounts", () => { + act(() => value.dismiss()); + expect(value.error).toBeNull(); + act(() => { + renderer.unmount(); + renderer = create(); + }); + expect(value.error).toBeNull(); + input.runtime = { lastError: "Durable failure", lastErrorAt: "2026-09-25T00:01:00Z" }; + render(); + expect(value.error).toBe("Durable failure"); +}); +it("preserves repeated draft occurrences and migrates the latest write on promotion", () => { + input.isServerThread = false; + input.runtime = null; + render(); + act(() => value.setError("draft", false, "Send failed")); + act(() => value.dismiss()); + act(() => value.setError("draft", false, "Send failed")); + expect(value.error).toBe("Send failed"); + input.isServerThread = true; + render(); + expect(value.error).toBe("Send failed"); + act(() => value.setError(input.threadKey, true, null)); + expect(value.error).toBeNull(); +}); diff --git a/apps/web/src/components/chat/useThreadErrorState.ts b/apps/web/src/components/chat/useThreadErrorState.ts new file mode 100644 index 000000000000..bba77b72a351 --- /dev/null +++ b/apps/web/src/components/chat/useThreadErrorState.ts @@ -0,0 +1,82 @@ +import type { OrchestrationV2ProviderFailureClass } from "@t3tools/contracts"; +import { useCallback, useEffect, useState } from "react"; +import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; +import { + dismissThreadErrorBannerForSession, + getThreadErrorBannerKey, + isThreadErrorBannerDismissedForSession, + resolveThreadErrorBanner, + shouldShowThreadErrorBanner, +} from "./ThreadErrorBanner"; + +type LocalErrorEntry = { message: string | null; at: number }; + +export function useThreadErrorState(input: { + threadKey: string; + draftId: string | null; + isServerThread: boolean; + runtime: { + readonly lastError: string | null; + readonly lastErrorAt?: string | null; + readonly lastErrorClass?: OrchestrationV2ProviderFailureClass | null; + } | null; +}) { + const { threadKey, draftId, isServerThread, runtime } = input; + const [serverErrors, setServerErrors] = useState>({}); + const [draftErrors, setDraftErrors] = useState>({}); + const [, setDismissTick] = useState(0); + useEffect(() => { + if (!isServerThread || !draftId) return; + const pending = draftErrors[draftId]; + if (pending === undefined) return; + setDraftErrors((existing) => { + const next = { ...existing }; + delete next[draftId]; + return next; + }); + setServerErrors((existing) => { + const current = existing[threadKey]; + if (current !== undefined && current.at > pending.at) return existing; + return { ...existing, [threadKey]: pending }; + }); + }, [draftErrors, draftId, isServerThread, threadKey]); + + const setError = useCallback((key: string, isServer: boolean, error: string | null) => { + const message = sanitizeThreadErrorMessage(error); + const at = Date.now(); + const update = isServer ? setServerErrors : setDraftErrors; + update((existing) => { + if (message === null && existing[key]?.message == null) return existing; + return { ...existing, [key]: { message, at: Math.max(at, (existing[key]?.at ?? 0) + 1) } }; + }); + }, []); + const local = isServerThread ? serverErrors[threadKey] : draftErrors[draftId ?? ""]; + const resolved = resolveThreadErrorBanner({ + threadKey, + localError: local?.message ?? null, + localErrorAt: local?.at ?? null, + runtime, + }); + const runtimeKey = getThreadErrorBannerKey( + threadKey, + runtime?.lastError ?? null, + runtime?.lastErrorAt ?? null, + ); + const dismiss = () => { + dismissThreadErrorBannerForSession(resolved.key, runtimeKey); + setDismissTick((tick) => tick + 1); + }; + return { + setError, + dismiss, + currentError: resolved.error, + error: shouldShowThreadErrorBanner( + threadKey, + resolved.error, + isThreadErrorBannerDismissedForSession(resolved.key), + ) + ? resolved.error + : null, + errorClass: resolved.key === runtimeKey ? (runtime?.lastErrorClass ?? null) : null, + }; +} diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 7163bd79a820..72a2ac903faa 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -54,6 +54,7 @@ export interface ThreadRuntimeSummary { readonly providerInstanceId: ProviderInstanceId; readonly providerName: string | null; readonly lastError: string | null; + readonly lastErrorAt?: string | null; readonly lastErrorClass?: OrchestrationV2ProviderFailureClass | null; readonly usageLimitResetAt?: string | null; readonly updatedAt: string; @@ -179,6 +180,7 @@ function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary providerInstanceId: thread.providerInstanceId, providerName: null, lastError: thread.lastError ?? null, + lastErrorAt: thread.lastErrorAt ?? null, lastErrorClass: thread.lastErrorClass ?? null, usageLimitResetAt: thread.usageLimitResetAt ?? null, updatedAt: iso(thread.updatedAt), diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index e728c1ae706b..eacc1c5e92c2 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -7,6 +7,9 @@ import { ThreadId, type OrchestrationV2ExecutionNode, type OrchestrationV2RunStatus, + type OrchestrationV2ProviderCapabilities, + ProviderSessionId, + ProviderDriverKind, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { usageLimitBlockedRun } from "@t3tools/shared/orchestrationV2ThreadError"; @@ -78,6 +81,55 @@ describe("thread execution presentation", () => { expect(deriveThreadRuntime(projection)).toMatchObject({ lastError: "Plan limit reached", lastErrorClass: "usage_limit", + lastErrorAt: DateTime.formatIso(now), + }); + const later = DateTime.makeUnsafe("2026-09-20T02:00:00Z"); + const session = { + id: ProviderSessionId.make("session:error"), + driver: ProviderDriverKind.make("pi"), + providerInstanceId: projection.thread.providerInstanceId, + status: "error" as const, + cwd: "/workspace", + model: null, + capabilities: {} as OrchestrationV2ProviderCapabilities, + createdAt: now, + updatedAt: later, + lastError: "Separate session failure", + lastErrorAt: later, + }; + expect(deriveThreadRuntime({ ...projection, providerSessions: [session] })).toMatchObject({ + lastError: session.lastError, + lastErrorClass: null, + lastErrorAt: DateTime.formatIso(later), + }); + expect( + deriveThreadRuntime({ ...projection, providerSessions: [{ ...session, lastErrorAt: null }] }), + ).toMatchObject({ + lastError: session.lastError, + lastErrorAt: null, + }); + expect( + deriveThreadRuntime({ + ...projection, + providerSessions: [{ ...session, lastError: item.failure.message }], + }), + ).toMatchObject({ + lastErrorClass: "usage_limit", + lastErrorAt: DateTime.formatIso(later), + }); + expect( + deriveThreadRuntime({ ...projection, providerSessions: [{ ...session, lastError: null }] }), + ).toMatchObject({ + lastErrorClass: "usage_limit", + lastErrorAt: DateTime.formatIso(now), + }); + expect( + deriveThreadRuntime({ + ...projection, + turnItems: [{ ...item, startedAt: null, completedAt: null, updatedAt: later }], + }), + ).toMatchObject({ + lastErrorAt: null, }); expect( deriveThreadRuntime({ diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 15a6f7b413df..0910a3540e8a 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -1,5 +1,6 @@ import { - latestRootProviderFailure, + latestRootProviderFailureItem, + providerFailureOccurredAt, threadErrorSummary, usageLimitRunPresentedAsLatest, } from "@t3tools/shared/orchestrationV2ThreadError"; @@ -216,6 +217,7 @@ export function deriveThreadRuntime( activeProviderThreadId: projection.thread.activeProviderThreadId, runs: projection.runs, }).length > 0; + const failureItem = latestRootProviderFailureItem(latestRunProjection, projection.turnItems); return { status: usageLimitedRun ? "failed" @@ -229,10 +231,13 @@ export function deriveThreadRuntime( : null, providerInstanceId: projection.thread.providerInstanceId, providerName: providerSession?.driver ?? null, - ...threadErrorSummary( - latestRootProviderFailure(latestRunProjection, projection.turnItems), - providerSession?.lastError ?? null, - ), + ...threadErrorSummary(failureItem?.failure ?? null, providerSession?.lastError ?? null, { + sessionErrorAt: + providerSession?.lastErrorAt == null + ? null + : DateTime.formatIso(providerSession.lastErrorAt), + failureAt: providerFailureOccurredAt(failureItem), + }), updatedAt: DateTime.formatIso(projection.updatedAt), }; } diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index f9f78baa11b8..cb550b2d9a5e 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -684,6 +684,7 @@ export const OrchestrationV2ProviderSession = Schema.Struct({ createdAt: Schema.DateTimeUtc, updatedAt: Schema.DateTimeUtc, lastError: Schema.NullOr(Schema.String), + lastErrorAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), }); export type OrchestrationV2ProviderSession = typeof OrchestrationV2ProviderSession.Type; @@ -1703,6 +1704,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ ), status: OrchestrationV2ShellThreadStatus, lastError: Schema.optional(Schema.NullOr(Schema.String)), + lastErrorAt: Schema.optional(Schema.NullOr(IsoDateTime)), lastErrorClass: Schema.optional(Schema.NullOr(OrchestrationV2ProviderFailureClass)), usageLimitResetAt: Schema.optional(Schema.NullOr(IsoDateTime)), pendingRuntimeRequest: Schema.NullOr(OrchestrationV2PendingRuntimeRequestSummary), @@ -1889,6 +1891,7 @@ export const OrchestrationV2ProviderSessionJson = OrchestrationV2ProviderSession ...fields, createdAt: Schema.DateTimeUtcFromString, updatedAt: Schema.DateTimeUtcFromString, + lastErrorAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), }), ); export type OrchestrationV2ProviderSessionJson = typeof OrchestrationV2ProviderSessionJson.Type; diff --git a/packages/shared/src/orchestrationV2ThreadError.ts b/packages/shared/src/orchestrationV2ThreadError.ts index 85f6ff956a76..19ffdf18bbeb 100644 --- a/packages/shared/src/orchestrationV2ThreadError.ts +++ b/packages/shared/src/orchestrationV2ThreadError.ts @@ -10,6 +10,13 @@ export function latestRootProviderFailure( run: OrchestrationV2Run | null, turnItems: ReadonlyArray, ): OrchestrationV2ProviderFailure | null { + return latestRootProviderFailureItem(run, turnItems)?.failure ?? null; +} + +export function latestRootProviderFailureItem( + run: OrchestrationV2Run | null, + turnItems: ReadonlyArray, +): Extract | null { if (run?.status !== "failed") return null; let latest: Extract | null = null; for (const item of turnItems) { @@ -29,13 +36,21 @@ export function latestRootProviderFailure( latest = item; } } - return latest?.failure ?? null; + return latest; +} + +export function providerFailureOccurredAt( + item: Extract | null, +): string | null { + const at = item?.completedAt ?? item?.startedAt; + return at == null ? null : DateTime.formatIso(at); } /** A distinct session failure supersedes the turn's classification. */ export function threadErrorSummary( failure: OrchestrationV2ProviderFailure | null, sessionError: string | null, + occurrence?: { readonly sessionErrorAt?: string | null; readonly failureAt?: string | null }, ) { const currentFailure = sessionError !== null && sessionError !== failure?.message ? null : failure; @@ -43,6 +58,13 @@ export function threadErrorSummary( usageLimitResetAt: currentFailure?.class === "usage_limit" ? (currentFailure.resetAt ?? null) : null, lastError: sessionError ?? failure?.message ?? null, + // Identity follows the source that supplied the text, not an unrelated refresh. + lastErrorAt: + sessionError !== null + ? (occurrence?.sessionErrorAt ?? null) + : failure !== null + ? (occurrence?.failureAt ?? null) + : null, lastErrorClass: sessionError !== null && sessionError !== failure?.message ? null : (failure?.class ?? null), }; From cc6ec4b9b426f15903caa022a8a26e53c674cc4c Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Fri, 25 Sep 2026 02:21:21 -0400 Subject: [PATCH 16/34] fix(pi): Fail turns that end on an unresolved abort --- .../Adapters/PiAdapterV2.test.ts | 502 +++++++++++++++++- .../orchestration-v2/Adapters/PiAdapterV2.ts | 22 +- .../pi_abort_outcome/scenarios.ndjson | 26 + 3 files changed, 547 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/pi_abort_outcome/scenarios.ndjson diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 8f9e23fb2037..714a8fdbb0e9 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -1,6 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + CommandId, + MessageId, + ProjectId, EnvironmentId, NodeId, ProviderInstanceId, @@ -24,6 +27,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; @@ -50,6 +54,10 @@ import { type ProviderAdapterV2SessionRuntime, } from "../ProviderAdapter.ts"; import { handoffBudget } from "../ContextHandoffBudget.ts"; +import * as Orchestrator from "../Orchestrator.ts"; +import * as EffectWorker from "../EffectWorker.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "../testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "../testkit/ReplayFixtureWorkspace.ts"; import { makePiAdapterV2, PI_PROVIDER, piLastErrorAt } from "./PiAdapterV2.ts"; import { makePiRpcConnection, type PiRpcRecord } from "./PiRpc.ts"; @@ -358,8 +366,12 @@ const openRuntime = Effect.fnUntraced(function* ( }); const emitted = yield* Queue.unbounded(); const eventsEnded = yield* Deferred.make(); + const observed: Array = []; yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(emitted, event)), + Stream.runForEach((event) => { + observed.push(event); + return Queue.offer(emitted, event); + }), Effect.ensuring(Deferred.succeed(eventsEnded, undefined)), Effect.forkScoped, ); @@ -370,7 +382,7 @@ const openRuntime = Effect.fnUntraced(function* ( if (predicate(event)) return event; } }); - return { runtime, takeEvent, eventsEnded: Deferred.await(eventsEnded) }; + return { runtime, takeEvent, observed, eventsEnded: Deferred.await(eventsEnded) }; }); const makeAppThread = Effect.fnUntraced(function* (model: string, threadId = THREAD_ID) { @@ -475,6 +487,431 @@ const expectModelFailure = (errorMessage: string) => ); }).pipe(Effect.scoped, Effect.provide(testLayer)); +const abortReplayCase = Schema.Struct({ + name: Schema.String, + events: Schema.Array(Schema.Record(Schema.String, Schema.Unknown)), + status: Schema.Literals(["failed", "completed"]), + error: Schema.NullOr(Schema.String), +}); +const decodeAbortReplayCase = Schema.decodeSync(Schema.fromJsonString(abortReplayCase)); +const abortReplayCaseNames = [ + "final-abort", + "recovered-stop", + "recovered-toolUse", + "recovered-length", + "recovered-then-second-abort", + "compaction-no-assistant", + "compaction-will-retry-no-assistant", + "cancelled-compaction", + "failed-compaction", + "custom-and-agent-start", + "compaction-and-assistant", + "noop-compaction-and-assistant", + "retry-abort-success-flag", + "retry-abort-specific-failure", + "specific-provider-failure", + "ordinary-retry", + "overflow-recovery", + "retry-exhaustion-compaction", + "normal-stop", + "normal-tool-use", + "pending-does-not-clear", + "deferred-does-not-clear", + "notifications", + "subordinate-abort", + "review-subordinate-after-last-main", + "review-notify-error-after-last-main", +]; + +// Synthetic protocol scenarios, not a wire capture. Only Pi's process is replaced; +// commands, ingestion, terminalization, checkpoints and projections are real. +describe("Pi abort outcome orchestrator replay", () => { + for (const name of abortReplayCaseNames) { + it.effect(name, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixturePath = yield* path.fromFileUrl( + new URL("../testkit/fixtures/pi_abort_outcome/scenarios.ndjson", import.meta.url), + ); + const transcript = yield* fs.readFileString(fixturePath); + const cases = transcript + .trim() + .split("\n") + .map((line) => decodeAbortReplayCase(line)); + assert.sameMembers( + cases.map((entry) => entry.name), + abortReplayCaseNames, + ); + const scenario = cases.find((entry) => entry.name === name)!; + const fake = yield* makeFakePi; + const adapter = yield* makeAdapter(fake); + const observed: Array = []; + const registry = ProviderAdapterRegistry.makeSingleLayer({ + ...adapter, + openSession: (input) => + adapter.openSession(input).pipe( + Effect.map((runtime) => ({ + ...runtime, + events: runtime.events.pipe( + Stream.tap((event) => Effect.sync(() => observed.push(event))), + ), + })), + ), + }); + const cwd = yield* checkpointWorkspace(`pi-abort-${scenario.name}`); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const store = yield* ProjectionStore.ProjectionStoreV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("pi-abort:create"), + threadId: THREAD_ID, + projectId: ProjectId.make("pi-abort:project"), + createdBy: "user", + creationSource: "web", + title: "Pi abort replay", + modelSelection: modelSelection("default"), + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("pi-abort:message"), + threadId: THREAD_ID, + messageId: MessageId.make("pi-abort:message"), + createdBy: "user", + creationSource: "web", + text: "Respond", + attachments: [], + modelSelection: modelSelection("default"), + dispatchMode: { type: "start_immediately" }, + }); + yield* fake.takeRequest("prompt"); + for (const event of scenario.events) yield* fake.emit(event); + yield* fake.emit({ type: "agent_settled" }); + yield* orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + (event.payload.status === "failed" || event.payload.status === "completed"), + ), + Stream.runHead, + ); + yield* worker.drain(); + const projection = yield* orchestrator.getThreadProjection(THREAD_ID); + assert.lengthOf(projection.runs, 1); + assert.equal(projection.runs[0]!.status, scenario.status); + assert.equal(projection.providerTurns.at(-1)?.status, scenario.status); + const terminals = observed.filter((event) => event.type === "turn.terminal"); + assert.lengthOf(terminals, 1); + assert.equal(terminals[0]?.threadDisposition, "reusable"); + const errors = observed.filter( + (event) => + event.type === "provider_session.updated" && event.providerSession.lastError !== null, + ); + assert.lengthOf(errors, scenario.error === null ? 0 : 1); + const session = projection.providerSessions.at(-1)!; + assert.equal(session.lastError, scenario.error); + if (scenario.error !== null) { + assert.isDefined(session.lastErrorAt); + assert.isNotNull(session.lastErrorAt); + const shell = (yield* store.getShellSnapshot()).threads.find( + (thread) => thread.id === THREAD_ID, + )!; + assert.equal(shell.lastError, scenario.error); + assert.equal(shell.lastErrorAt, DateTime.formatIso(session.lastErrorAt!)); + yield* TestClock.adjust("1 second"); + yield* manager.release({ providerSessionId: session.id, reason: "idle_timeout" }); + const after = (yield* store.getThreadProviderContext(THREAD_ID)).providerSessions.at( + -1, + )!; + assert.equal(after.lastError, scenario.error); + assert.deepEqual(after.lastErrorAt, session.lastErrorAt); + } + assert.lengthOf( + fake.allRequests().filter((request) => request["type"] === "prompt"), + 1, + ); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { + name: `pi-abort-${scenario.name}`, + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly" }, + }, + }, + registry, + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + } +}); + +describe("Pi abort correlated RPC boundaries", () => { + it.effect("keeps Stop interrupted at confirmed idle after an assistant abort", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + assert.equal(running.type, "provider_turn.updated"); + if (running.type !== "provider_turn.updated") return; + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "message_end", + message: { role: "assistant", content: [], stopReason: "aborted" }, + }); + yield* runtime.readThreadSnapshot({ providerThread }); + yield* runtime.interruptTurn({ providerThread, providerTurnId: running.providerTurn.id }); + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.equal(terminal.type, "turn.terminal"); + if (terminal.type !== "turn.terminal") return; + assert.equal(terminal.status, "interrupted"); + assert.isNull(terminal.failure); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("a failed idle probe cannot complete an unresolved abort", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "message_end", + message: { role: "assistant", content: [], stopReason: "aborted" }, + }); + fake.failNextState(); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + yield* runtime.readThreadSnapshot({ providerThread }); + yield* fake.closeStdout; + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.equal(terminal.type, "turn.terminal"); + if (terminal.type !== "turn.terminal") return; + assert.equal(terminal.status, "failed"); + assert.equal(terminal.failure?.class, "transport_error"); + assert.equal(terminal.failure?.message, "Pi process exited unexpectedly."); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + for (const stop of [false, true]) { + for (const phase of ["streaming", "retry", "compaction"] as const) { + it.effect(`${stop ? "Stop" : "transport failure"} after abort during ${phase}`, () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + assert.equal(running.type, "provider_turn.updated"); + if (running.type !== "provider_turn.updated") return; + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "message_end", + message: { role: "assistant", content: [], stopReason: "aborted" }, + }); + if (phase === "compaction") + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + if (phase === "retry") + yield* fake.emit({ type: "auto_retry_start", attempt: 1, maxAttempts: 2, delayMs: 1 }); + yield* fake.emit({ + type: "extension_ui_request", + method: "notify", + message: "pump fence", + }); + yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.toolName === "notify", + ); + if (stop) + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: running.providerTurn.id, + }); + yield* fake.closeStdout; + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.equal(terminal.type, "turn.terminal"); + if (terminal.type !== "turn.terminal") return; + assert.equal(terminal.status, stop ? "interrupted" : "failed"); + if (stop) assert.isNull(terminal.failure); + else { + assert.equal(terminal.failure?.class, "transport_error"); + assert.equal(terminal.failure?.message, "Pi process exited unexpectedly."); + } + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + } + } + + it.effect("invalidates a held abort idle probe when recovery starts", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent, observed } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "message_end", + message: { role: "assistant", content: [], stopReason: "aborted" }, + }); + fake.deferNextState(); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + fake.queueState({ isStreaming: true, isCompacting: false, pendingMessageCount: 0 }); + yield* fake.emit({ + type: "compaction_end", + result: { summary: "smaller" }, + willRetry: false, + }); + yield* fake.takeRequest("get_state"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.resolveDeferredState({ + isStreaming: false, + isCompacting: false, + pendingMessageCount: 0, + }); + // A subsequent correlated request puts the stale probe ahead of recovery's + // successful completion without relying on a wall-clock absence check. + yield* runtime.readThreadSnapshot({ providerThread }); + yield* fake.emit({ + type: "message_end", + message: { role: "assistant", content: [], stopReason: "stop" }, + }); + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.equal(terminal.type, "turn.terminal"); + if (terminal.type !== "turn.terminal") return; + assert.equal(terminal.status, "completed"); + assert.lengthOf( + observed.filter((event) => event.type === "turn.terminal"), + 1, + ); + assert.lengthOf( + observed.filter( + (event) => + event.type === "provider_session.updated" && event.providerSession.lastError !== null, + ), + 0, + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("publishes abort once and isolates duplicate settlement and late old-turn probes", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent, observed } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const occurrences: Array = []; + for (const runOrdinal of [1, 2, 3]) { + yield* startTurn(runtime, providerThread, "default", [], "Respond", undefined, runOrdinal); + yield* fake.takeRequest("prompt"); + yield* TestClock.adjust("1 second"); + yield* fake.emit({ type: "agent_start" }); + if (runOrdinal === 2) { + yield* fake.resolveDeferredState({ + isStreaming: false, + isCompacting: false, + pendingMessageCount: 0, + }); + yield* runtime.readThreadSnapshot({ providerThread }); + } + const stopReason = runOrdinal === 2 ? "stop" : "aborted"; + yield* fake.emit({ + type: "message_end", + message: { role: "assistant", content: [], stopReason }, + }); + yield* fake.emit({ + type: "message_end", + message: { role: "assistant", content: [], stopReason }, + }); + if (runOrdinal === 1) { + fake.deferNextState(); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + } + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.equal(terminal.type, "turn.terminal"); + if (terminal.type !== "turn.terminal") return; + assert.equal(terminal.status, runOrdinal === 2 ? "completed" : "failed"); + assert.equal(terminal.runOrdinal, runOrdinal); + const updates = observed.filter((event) => event.type === "provider_session.updated"); + const session = updates.at(-1)!.providerSession; + if (runOrdinal === 2) { + assert.isNull(session.lastError); + assert.isNull(session.lastErrorAt); + } else { + assert.equal(session.lastError, "Pi aborted the response before completion."); + assert.isDefined(session.lastErrorAt); + assert.isNotNull(session.lastErrorAt); + occurrences.push(DateTime.toEpochMillis(session.lastErrorAt!)); + } + } + assert.lengthOf( + observed.filter((event) => event.type === "turn.terminal"), + 3, + ); + assert.lengthOf( + observed.filter( + (event) => + event.type === "provider_session.updated" && event.providerSession.lastError !== null, + ), + 2, + ); + assert.isAbove(occurrences[1]!, occurrences[0]!); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); +}); + describe("PiAdapterV2", () => { it("keeps the occurrence identity on same-text refreshes, including legacy null", () => { const now = DateTime.makeUnsafe("2026-09-20T12:00:00Z"); @@ -2299,6 +2736,10 @@ describe("PiAdapterV2", () => { yield* fake.takeRequest("prompt"); yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "message_end", + message: { role: "assistant", content: [], stopReason: "aborted" }, + }); // Extension ctx.compact() waits for this first settlement, then starts // compaction in a detached continuation. fake.queueState({ isStreaming: false, isCompacting: true, pendingMessageCount: 0 }); @@ -2325,6 +2766,10 @@ describe("PiAdapterV2", () => { event.turnItem.status === "completed", ); yield* fake.takeRequest("get_state"); + yield* fake.emit({ + type: "message_end", + message: { role: "assistant", content: [], stopReason: "stop" }, + }); fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); yield* fake.emit({ type: "agent_settled" }); @@ -2658,3 +3103,56 @@ describe("PiRpc early process exit", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); }); + +describe("B09 review probes", () => { + it.effect("review: late old-turn probe with a coinciding generation stays isolated", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent, observed } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread, "default", [], "Respond", undefined, 1); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "message_end", + message: { role: "assistant", content: [], stopReason: "aborted" }, + }); + fake.deferNextState(); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + yield* fake.emit({ type: "agent_settled" }); + const first = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.equal(first.type === "turn.terminal" ? first.status : null, "failed"); + yield* startTurn(runtime, providerThread, "default", [], "Respond", undefined, 2); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "message_end", + message: { role: "assistant", content: [], stopReason: "aborted" }, + }); + yield* fake.emit({ type: "agent_start" }); + yield* fake.resolveDeferredState({ + isStreaming: false, + isCompacting: false, + pendingMessageCount: 0, + }); + yield* runtime.readThreadSnapshot({ providerThread }); + yield* fake.emit({ + type: "message_end", + message: { role: "assistant", content: [], stopReason: "stop" }, + }); + yield* fake.emit({ type: "agent_settled" }); + const second = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.equal(second.type === "turn.terminal" ? second.status : null, "completed"); + assert.equal(second.type === "turn.terminal" ? second.runOrdinal : null, 2); + assert.lengthOf( + observed.filter((event) => event.type === "turn.terminal"), + 2, + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 1a3debeba56b..0f01f506306a 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -317,6 +317,7 @@ interface ActivePiTurn { */ readonly toolStartedAt: Map; interrupted: boolean; + unresolvedAssistantAbort: boolean; /** * Whether any agent run activity was observed. Command-only prompts (pure * extension slash commands) never start an agent run and never emit @@ -1621,7 +1622,17 @@ export function makePiAdapterV2( const message = event["message"]; if (recordString(message, "role") !== "assistant") return; yield* completeOpenStreamItems(turn); - if (recordString(message, "stopReason") === "error" && turn.failure === null) { + const stopReason = recordString(message, "stopReason"); + if (stopReason === "aborted") { + turn.unresolvedAssistantAbort = true; + } else if ( + stopReason === "stop" || + stopReason === "toolUse" || + stopReason === "length" + ) { + turn.unresolvedAssistantAbort = false; + } + if (stopReason === "error" && turn.failure === null) { turn.failure = makeProviderFailure({ message: recordString(message, "errorMessage") ?? "Pi reported a model error.", class: "provider_error", @@ -1932,6 +1943,14 @@ export function makePiAdapterV2( (recordNumber(data, "pendingMessageCount") ?? 0) === 0 ) { turn.settleWhenIdle = false; + // An aborted attempt can recover through extension work. Classify it + // only after the current turn's idle probe confirms settlement. + if (!turn.interrupted && turn.failure === null && turn.unresolvedAssistantAbort) { + turn.failure = makeProviderFailure({ + message: "Pi aborted the response before completion.", + class: "provider_error", + }); + } if (state !== null) yield* finalizeTurn(state); } return; @@ -2347,6 +2366,7 @@ export function makePiAdapterV2( toolArgs: new Map(), toolStartedAt: new Map(), interrupted: false, + unresolvedAssistantAbort: false, sawAgentActivity: false, promptMayBeCommandOnly: compactCommand !== null || (payload?.message.trimStart().startsWith("/") ?? false), diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/pi_abort_outcome/scenarios.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/pi_abort_outcome/scenarios.ndjson new file mode 100644 index 000000000000..457ef1aeb371 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/pi_abort_outcome/scenarios.ndjson @@ -0,0 +1,26 @@ +{"name":"final-abort","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}}],"status":"failed","error":"Pi aborted the response before completion."} +{"name":"recovered-stop","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"stop"}}],"status":"completed","error":null} +{"name":"recovered-toolUse","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"toolUse"}}],"status":"completed","error":null} +{"name":"recovered-length","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"length"}}],"status":"completed","error":null} +{"name":"recovered-then-second-abort","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"toolUse"}},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}}],"status":"failed","error":"Pi aborted the response before completion."} +{"name":"compaction-no-assistant","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"compaction_start","reason":"manual"},{"type":"compaction_end","result":{"summary":"smaller","tokensBefore":10000,"estimatedTokensAfter":2000},"aborted":false,"willRetry":false}],"status":"failed","error":"Pi aborted the response before completion."} +{"name":"compaction-will-retry-no-assistant","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"compaction_start","reason":"manual"},{"type":"compaction_end","result":{"summary":"smaller","tokensBefore":10000,"estimatedTokensAfter":2000},"aborted":false,"willRetry":true}],"status":"failed","error":"Pi aborted the response before completion."} +{"name":"cancelled-compaction","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"compaction_start","reason":"manual"},{"type":"compaction_end","aborted":true}],"status":"failed","error":"Pi aborted the response before completion."} +{"name":"failed-compaction","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"compaction_start","reason":"manual"},{"type":"compaction_end","errorMessage":"compact failed"}],"status":"failed","error":"Pi aborted the response before completion."} +{"name":"custom-and-agent-start","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"message_end","message":{"role":"custom","content":[],"stopReason":"aborted","customType":"pi-xai-ws-loop-recovery"}},{"type":"agent_start"}],"status":"failed","error":"Pi aborted the response before completion."} +{"name":"compaction-and-assistant","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"compaction_start","reason":"manual"},{"type":"compaction_end","result":{"summary":"smaller","tokensBefore":10000,"estimatedTokensAfter":2000},"aborted":false,"willRetry":false},{"type":"message_end","message":{"role":"custom","content":[],"stopReason":"aborted","customType":"pi-xai-ws-loop-recovery"}},{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"stop"}}],"status":"completed","error":null} +{"name":"noop-compaction-and-assistant","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"compaction_start","reason":"manual"},{"type":"compaction_end","errorMessage":"too small"},{"type":"message_end","message":{"role":"custom","content":[],"stopReason":"aborted","customType":"pi-xai-ws-loop-recovery"}},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"stop"}}],"status":"completed","error":null} +{"name":"retry-abort-success-flag","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"error","errorMessage":"capacity exhausted"}},{"type":"auto_retry_start","attempt":1,"maxAttempts":2,"delayMs":1,"errorMessage":"capacity exhausted"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"auto_retry_end","success":true,"attempt":1}],"status":"failed","error":"Pi aborted the response before completion."} +{"name":"retry-abort-specific-failure","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"error","errorMessage":"capacity exhausted"}},{"type":"auto_retry_start","attempt":1,"maxAttempts":2,"delayMs":1,"errorMessage":"capacity exhausted"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"auto_retry_end","success":false,"finalError":"retry exhausted","attempt":2}],"status":"failed","error":"retry exhausted"} +{"name":"specific-provider-failure","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"error","errorMessage":"capacity exhausted"}},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}}],"status":"failed","error":"capacity exhausted"} +{"name":"ordinary-retry","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"error","errorMessage":"capacity exhausted"}},{"type":"auto_retry_start","attempt":1,"maxAttempts":2,"delayMs":1,"errorMessage":"capacity exhausted"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"stop"}},{"type":"auto_retry_end","success":true,"attempt":1}],"status":"completed","error":null} +{"name":"overflow-recovery","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"error","errorMessage":"capacity exhausted"}},{"type":"compaction_start","reason":"manual"},{"type":"compaction_end","result":{"summary":"smaller","tokensBefore":10000,"estimatedTokensAfter":2000},"aborted":false,"willRetry":true},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"stop"}}],"status":"completed","error":null} +{"name":"retry-exhaustion-compaction","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"error","errorMessage":"capacity exhausted"}},{"type":"auto_retry_start","attempt":1,"maxAttempts":2,"delayMs":1,"errorMessage":"capacity exhausted"},{"type":"auto_retry_end","success":false,"finalError":"retry exhausted","attempt":2},{"type":"compaction_start","reason":"manual"},{"type":"compaction_end","result":{"summary":"smaller","tokensBefore":10000,"estimatedTokensAfter":2000},"aborted":false,"willRetry":false}],"status":"failed","error":"retry exhausted"} +{"name":"normal-stop","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"stop"}}],"status":"completed","error":null} +{"name":"normal-tool-use","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"toolUse"}}],"status":"completed","error":null} +{"name":"pending-does-not-clear","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"pending"}}],"status":"failed","error":"Pi aborted the response before completion."} +{"name":"deferred-does-not-clear","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"aborted"}},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"deferred"}}],"status":"failed","error":"Pi aborted the response before completion."} +{"name":"notifications","events":[{"type":"agent_start"},{"type":"extension_ui_request","id":"notify:info","method":"notify","notifyType":"info","message":"Recovery cooldown exhausted; response aborted"},{"type":"extension_ui_request","id":"notify:warning","method":"notify","notifyType":"warning","message":"Recovery cooldown exhausted; response aborted"},{"type":"extension_ui_request","id":"notify:error","method":"notify","notifyType":"error","message":"Recovery cooldown exhausted; response aborted"},{"type":"extension_error","extensionPath":"test","event":"turn_end","error":"aborted"},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"stop"}}],"status":"completed","error":null} +{"name":"subordinate-abort","events":[{"type":"agent_start"},{"type":"message_end","message":{"role":"toolResult","content":[],"stopReason":"aborted"}},{"type":"tool_execution_start","toolCallId":"child","toolName":"subagent","args":{}},{"type":"tool_execution_end","toolCallId":"child","toolName":"subagent","result":{"content":[{"type":"text","text":"subagent aborted"}],"details":{"stopReason":"aborted"}},"isError":true},{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"stop"}}],"status":"completed","error":null} +{"name": "review-subordinate-after-last-main", "status": "completed", "error": null, "events": [{"type": "agent_start"}, {"type": "message_end", "message": {"role": "assistant", "content": [], "stopReason": "toolUse"}}, {"type": "tool_execution_start", "toolCallId": "child", "toolName": "subagent", "args": {}}, {"type": "tool_execution_end", "toolCallId": "child", "toolName": "subagent", "result": {"content": [{"type": "text", "text": "subagent aborted"}], "details": {"stopReason": "aborted"}}, "isError": true}, {"type": "message_end", "message": {"role": "toolResult", "content": [], "stopReason": "aborted"}}]} +{"name": "review-notify-error-after-last-main", "status": "completed", "error": null, "events": [{"type": "agent_start"}, {"type": "message_end", "message": {"role": "assistant", "content": [], "stopReason": "stop"}}, {"type": "extension_ui_request", "id": "n1", "method": "notify", "notifyType": "error", "message": "Loop recovery exhausted after repeated output; cooldown active."}, {"type": "extension_error", "extensionPath": "/x/pi-xai-ws", "event": "turn_end", "error": "aborted"}]} From 5fe2c77efe555674f8d03bbe047c055eec47962e Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Fri, 25 Sep 2026 04:15:18 -0400 Subject: [PATCH 17/34] fix(orchestrator): Settle inherited work when provider starts fail --- .../src/orchestration-v2/EffectOutbox.ts | 56 + .../src/orchestration-v2/EffectWorker.test.ts | 270 ++- .../src/orchestration-v2/EffectWorker.ts | 142 +- .../FoundationPersistence.test.ts | 1531 ++++++++++++++++- .../ProviderRuntimeRecoveryService.test.ts | 92 + .../ProviderRuntimeRecoveryService.ts | 43 +- .../ProviderTurnStartService.ts | 510 +++++- .../src/orchestration-v2/RepairRetry.test.ts | 166 ++ .../RetryReplay.integration.test.ts | 100 ++ 9 files changed, 2864 insertions(+), 46 deletions(-) create mode 100644 apps/server/src/orchestration-v2/RepairRetry.test.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/exhausted_start_settlement/RetryReplay.integration.test.ts diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 0e55a923f301..22168cef3b8b 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -36,6 +36,12 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ revokeMcpCredential: Schema.optional(Schema.Boolean), preserveBufferedOutput: Schema.optional(Schema.Boolean), }), + Schema.Struct({ + type: Schema.Literal("provider-turn.repair"), + runId: RunId, + attemptId: Schema.NullOr(RunAttemptId), + error: Schema.String, + }), Schema.Struct({ type: Schema.Literal("provider-turn.start"), runId: RunId, @@ -110,6 +116,7 @@ export const REPLAY_SAFE_EFFECT_TYPES_AFTER_PROCESS_LOSS = [ "provider-runtime.continue", "provider-session.detach", "provider-thread.rollback", + "provider-turn.repair", "checkpoint.capture", "terminal.cleanup", "attachment.cleanup", @@ -203,6 +210,15 @@ export interface EffectOutboxV2Shape { readonly excludeRestartContinuations?: boolean; }) => Effect.Effect, EffectOutboxError>; readonly nextClaimableAt: Effect.Effect, EffectOutboxError>; + readonly getRepairRunIds: ( + threadId: ThreadId, + ) => Effect.Effect, EffectOutboxError>; + readonly beginRepair: (input: { + readonly effectId: string; + readonly workerId: string; + readonly runId: RunId; + readonly error: string; + }) => Effect.Effect; readonly succeed: (input: { readonly effectId: string; readonly workerId: string; @@ -529,6 +545,46 @@ export const layer: Layer.Layer = La : new EffectOutboxError({ operation: "next-claimable", cause }), ), ), + getRepairRunIds: (threadId) => + sql<{ readonly run_id: string }>` + SELECT r.run_id FROM orchestration_v2_projection_runs AS r + JOIN orchestration_v2_effect_outbox AS e ON e.thread_id = r.thread_id + AND json_extract(e.payload_json, '$.runId') = r.run_id + AND json_extract(e.payload_json, '$.attemptId') = json_extract(r.payload_json, '$.activeAttemptId') + WHERE r.thread_id = ${threadId} AND r.status = 'starting' + AND e.effect_type = 'provider-turn.repair' AND e.status IN ('pending', 'running') + `.pipe( + Effect.map((rows) => rows.map((row) => RunId.make(row.run_id))), + Effect.mapError((cause) => new EffectOutboxError({ operation: "repair-runs", cause })), + ), + beginRepair: ({ effectId, workerId, runId, error }) => + Effect.gen(function* () { + const payload = yield* Schema.encodeEffect( + Schema.fromJsonString(OrchestrationEffectRequestV2), + )({ + type: "provider-turn.repair", + runId, + attemptId: null, + error, + }); + const now = DateTime.formatIso(yield* DateTime.now); + const rows = yield* sql<{ readonly effect_id: string }>` + UPDATE orchestration_v2_effect_outbox + SET effect_type = 'provider-turn.repair', payload_json = json_set(${payload}, '$.attemptId', ( + SELECT json_extract(r.payload_json, '$.activeAttemptId') + FROM orchestration_v2_projection_runs AS r + WHERE r.run_id = ${runId} AND r.thread_id = orchestration_v2_effect_outbox.thread_id + )), updated_at = ${now} + WHERE effect_id = ${effectId} AND status = 'running' AND lease_owner = ${workerId} + AND effect_type IN ('provider-turn.start', 'provider-turn.restart') + RETURNING effect_id + `; + return rows.length === 1; + }).pipe( + Effect.mapError( + (cause) => new EffectOutboxError({ operation: "begin-repair", effectId, cause }), + ), + ), succeed: ({ effectId, workerId }) => Effect.gen(function* () { const now = DateTime.formatIso(yield* DateTime.now); diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index af2667a8cf8e..a7b9a9722ed9 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -13,6 +13,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; @@ -79,6 +80,7 @@ function restartEffect( function makeExecutorLayer(input: { readonly events: Ref.Ref>; readonly failFirstStart?: Ref.Ref; + readonly failDeadLetterCompensation?: Ref.Ref; }) { const record = (event: string) => Ref.update(input.events, (events) => [...events, event]); const dependencies = Layer.mergeAll( @@ -123,6 +125,19 @@ function makeExecutorLayer(input: { }); } }), + failFromDeadLetter: (request) => + Effect.gen(function* () { + yield* record(`failFromDeadLetter:${request.runId}`); + if ( + input.failDeadLetterCompensation !== undefined && + (yield* Ref.get(input.failDeadLetterCompensation)) + ) { + return yield* new ProviderTurnStartService.ProviderTurnStartError({ + runId: request.runId, + cause: "simulated dead-letter compensation failure", + }); + } + }), }), ), Layer.succeed( @@ -234,6 +249,7 @@ it.effect("requeues a claim when a pre-execution worker check fails", () => const executorLayer = Layer.succeed( EffectWorker.OrchestrationEffectExecutorV2, EffectWorker.OrchestrationEffectExecutorV2.of({ + compensateDeadLetter: () => Effect.void, execute: () => Ref.update(executionCount, (count) => count + 1), }), ); @@ -307,6 +323,7 @@ it.effect("arms cancellation before the durable pre-execution check", () => const executorLayer = Layer.succeed( EffectWorker.OrchestrationEffectExecutorV2, EffectWorker.OrchestrationEffectExecutorV2.of({ + compensateDeadLetter: () => Effect.void, execute: () => Effect.yieldNow.pipe(Effect.andThen(Ref.update(executionCount, (count) => count + 1))), }), @@ -375,6 +392,7 @@ it.effect("terminalizes a process-bound claim when success settlement fails", () const executorLayer = Layer.succeed( EffectWorker.OrchestrationEffectExecutorV2, EffectWorker.OrchestrationEffectExecutorV2.of({ + compensateDeadLetter: () => Effect.void, execute: () => Ref.update(executionCount, (count) => count + 1), }), ); @@ -438,7 +456,10 @@ it.effect("requeues a replay-safe claim when success settlement fails", () => }); const executorLayer = Layer.succeed( EffectWorker.OrchestrationEffectExecutorV2, - EffectWorker.OrchestrationEffectExecutorV2.of({ execute: () => Effect.void }), + EffectWorker.OrchestrationEffectExecutorV2.of({ + compensateDeadLetter: () => Effect.void, + execute: () => Effect.void, + }), ); const workerLayer = EffectWorker.layerWithOptions({ workerId }).pipe( Layer.provide(Layer.merge(outboxLayer, executorLayer)), @@ -505,6 +526,7 @@ it.effect("keeps a process-bound executor failure retryable when retry settlemen const executorLayer = Layer.succeed( EffectWorker.OrchestrationEffectExecutorV2, EffectWorker.OrchestrationEffectExecutorV2.of({ + compensateDeadLetter: () => Effect.void, execute: () => Effect.fail( new EffectWorker.OrchestrationEffectExecutionError({ @@ -577,6 +599,7 @@ it.effect("keeps a max-attempt replay-safe failure terminal when fail settlement const executorLayer = Layer.succeed( EffectWorker.OrchestrationEffectExecutorV2, EffectWorker.OrchestrationEffectExecutorV2.of({ + compensateDeadLetter: () => Effect.void, execute: () => Effect.fail( new EffectWorker.OrchestrationEffectExecutionError({ @@ -753,3 +776,248 @@ it.effect("safely retries after replacement cleanup succeeds and start fails", ( ]); }), ); + +function startEffect(now: DateTime.Utc): EffectOutbox.OrchestrationEffectV2 { + const timestamp = DateTime.formatIso(now); + return { + id: "effect:dead-letter:start", + commandId: CommandId.make("command:dead-letter:start"), + threadId, + request: { type: "provider-turn.start", runId }, + status: "running", + attemptCount: 5, + availableAt: timestamp, + leaseOwner: "test-worker", + leaseExpiresAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp, + completedAt: null, + lastError: null, + }; +} + +it.effect("compensates a dead-lettered turn start before the terminal fail settles", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const workerId = "worker-dead-letter-compensation"; + const claimedEffect = { ...startEffect(now), leaseOwner: workerId }; + const order = yield* Ref.make>([]); + const compensations = yield* Ref.make< + ReadonlyArray<{ readonly effectId: string; readonly error: string }> + >([]); + const outboxLayer = Layer.mock(EffectOutbox.EffectOutboxV2)({ + beginRepair: () => Effect.succeed(true), + claimNext: () => Effect.succeed(Option.some(claimedEffect)), + get: () => Effect.succeed(Option.some(claimedEffect)), + awaitCancellation: () => Effect.never, + clearCancellation: () => Effect.void, + fail: () => Ref.update(order, (existing) => [...existing, "fail"]).pipe(Effect.as(true)), + }); + const executorLayer = Layer.succeed( + EffectWorker.OrchestrationEffectExecutorV2, + EffectWorker.OrchestrationEffectExecutorV2.of({ + compensateDeadLetter: (effect, error) => + Ref.update(order, (existing) => [...existing, "compensate"]).pipe( + Effect.andThen( + Ref.update(compensations, (existing) => [ + ...existing, + { effectId: effect.id, error }, + ]), + ), + ), + execute: (effect) => + Effect.fail( + new EffectWorker.OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause: "simulated terminal start failure", + }), + ), + }), + ); + const workerLayer = EffectWorker.layerWithOptions({ workerId, maxAttempts: 5 }).pipe( + Layer.provide(Layer.merge(outboxLayer, executorLayer)), + ); + + assert.isTrue( + yield* EffectWorker.OrchestrationEffectWorkerV2.pipe( + Effect.flatMap((worker) => worker.runOnce), + Effect.provide(workerLayer), + ), + ); + + // Compensation must be durable before the outbox row turns terminal: a + // crash between the two leaves the effect unsettled for startup reconcile + // rather than a terminal effect with a hung run. + assert.deepEqual(yield* Ref.get(order), ["compensate", "fail"]); + const compensation = (yield* Ref.get(compensations))[0]; + assert.isDefined(compensation); + assert.equal(compensation.effectId, claimedEffect.id); + assert.include(compensation.error, "simulated terminal start failure"); + assert.lengthOf(yield* Ref.get(compensations), 1); + }), +); + +it.effect("settles a dead letter when interrupted during compensation", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const workerId = "worker-dead-letter-compensation-interrupt"; + const claimedEffect = { ...startEffect(now), leaseOwner: workerId }; + const compensationStarted = yield* Deferred.make(); + const releaseCompensation = yield* Deferred.make(); + const order = yield* Ref.make>([]); + const outboxLayer = Layer.mock(EffectOutbox.EffectOutboxV2)({ + beginRepair: () => Effect.succeed(true), + claimNext: () => Effect.succeed(Option.some(claimedEffect)), + get: () => Effect.succeed(Option.some(claimedEffect)), + awaitCancellation: () => Effect.never, + clearCancellation: () => Effect.void, + fail: () => Ref.update(order, (existing) => [...existing, "fail"]).pipe(Effect.as(true)), + }); + const executorLayer = Layer.succeed( + EffectWorker.OrchestrationEffectExecutorV2, + EffectWorker.OrchestrationEffectExecutorV2.of({ + compensateDeadLetter: () => + Deferred.succeed(compensationStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCompensation)), + Effect.andThen(Ref.update(order, (existing) => [...existing, "compensate"])), + Effect.catchCause(() => Effect.void), + ), + execute: (effect) => + Effect.fail( + new EffectWorker.OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause: "simulated terminal start failure", + }), + ), + }), + ); + const workerLayer = EffectWorker.layerWithOptions({ workerId, maxAttempts: 5 }).pipe( + Layer.provide(Layer.merge(outboxLayer, executorLayer)), + ); + + yield* Effect.gen(function* () { + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const workerFiber = yield* worker.runOnce.pipe(Effect.forkChild); + yield* Deferred.await(compensationStarted); + const interruption = yield* Fiber.interrupt(workerFiber).pipe(Effect.forkChild); + yield* Effect.yieldNow; + assert.isUndefined(interruption.pollUnsafe()); + assert.deepEqual(yield* Ref.get(order), []); + yield* Deferred.succeed(releaseCompensation, undefined); + yield* Fiber.await(workerFiber); + yield* Fiber.join(interruption); + }).pipe(Effect.provide(workerLayer)); + + assert.deepEqual(yield* Ref.get(order), ["compensate", "fail"]); + }), +); + +it.effect("accepts durable cancellation when terminal outbox settlement loses ownership", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const workerId = "worker-dead-letter-lost-lease"; + const claimedEffect = { ...startEffect(now), leaseOwner: workerId }; + const compensationCount = yield* Ref.make(0); + // The row is still owned at claim time; the cancellation lands while the + // execution fails, so the terminal fail loses the lease and the post-fail + // re-read sees the cancelled row. + const failAttempted = yield* Ref.make(false); + const outboxLayer = Layer.mock(EffectOutbox.EffectOutboxV2)({ + beginRepair: () => Effect.succeed(true), + claimNext: () => Effect.succeed(Option.some(claimedEffect)), + get: () => + Ref.get(failAttempted).pipe( + Effect.map((cancelled) => + Option.some( + cancelled + ? { + ...claimedEffect, + status: "cancelled" as const, + leaseOwner: null, + leaseExpiresAt: null, + completedAt: DateTime.formatIso(now), + } + : claimedEffect, + ), + ), + ), + awaitCancellation: () => Effect.never, + clearCancellation: () => Effect.void, + fail: () => Ref.set(failAttempted, true).pipe(Effect.as(false)), + }); + const executorLayer = Layer.succeed( + EffectWorker.OrchestrationEffectExecutorV2, + EffectWorker.OrchestrationEffectExecutorV2.of({ + compensateDeadLetter: () => Ref.update(compensationCount, (count) => count + 1), + execute: (effect) => + Effect.fail( + new EffectWorker.OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause: "simulated terminal start failure", + }), + ), + }), + ); + const workerLayer = EffectWorker.layerWithOptions({ workerId, maxAttempts: 5 }).pipe( + Layer.provide(Layer.merge(outboxLayer, executorLayer)), + ); + + assert.isTrue( + yield* EffectWorker.OrchestrationEffectWorkerV2.pipe( + Effect.flatMap((worker) => worker.runOnce), + Effect.provide(workerLayer), + ), + ); + + assert.equal(yield* Ref.get(compensationCount), 1); + }), +); + +it.effect("routes dead-letter compensation for start and restart to the run failure path", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Ref.make>([]); + + yield* Effect.gen(function* () { + const executor = yield* EffectWorker.OrchestrationEffectExecutorV2; + yield* executor.compensateDeadLetter(startEffect(now), "dead-letter error"); + yield* executor.compensateDeadLetter( + restartEffect(now, { type: "detach" }), + "dead-letter error", + ); + yield* executor.compensateDeadLetter( + { + ...startEffect(now), + request: { type: "terminal.cleanup" }, + }, + "dead-letter error", + ); + }).pipe(Effect.provide(makeExecutorLayer({ events }))); + + assert.deepEqual(yield* Ref.get(events), [ + `failFromDeadLetter:${runId}`, + `failFromDeadLetter:${runId}`, + ]); + }), +); + +it.effect("propagates a failing dead-letter compensation for retry", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Ref.make>([]); + const failDeadLetterCompensation = yield* Ref.make(true); + + yield* Effect.gen(function* () { + const executor = yield* EffectWorker.OrchestrationEffectExecutorV2; + const result = yield* executor + .compensateDeadLetter(startEffect(now), "dead-letter error") + .pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }).pipe(Effect.provide(makeExecutorLayer({ events, failDeadLetterCompensation }))); + + assert.deepEqual(yield* Ref.get(events), [`failFromDeadLetter:${runId}`]); + }), +); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 57b9c2b144a7..52d676eef008 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -71,6 +71,11 @@ export interface OrchestrationEffectExecutorV2Shape { effect: EffectOutbox.OrchestrationEffectV2, options?: { readonly willRetry: boolean }, ) => Effect.Effect; + /** Settle owned work before the outbox becomes terminal. Failed repairs remain retryable. */ + readonly compensateDeadLetter: ( + effect: EffectOutbox.OrchestrationEffectV2, + error: string, + ) => Effect.Effect; } export class OrchestrationEffectExecutorV2 extends Context.Service< @@ -147,6 +152,24 @@ export const executorLayer: Layer.Layer< }), ), ); + case "provider-turn.repair": + return providerTurnStart + .failFromDeadLetter({ + threadId: effect.threadId, + runId: effect.request.runId, + error: effect.request.error, + expectedAttemptId: effect.request.attemptId, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause, + }), + ), + ); case "provider-turn.start": return providerTurnStart .start({ threadId: effect.threadId, runId: effect.request.runId, willRetry }) @@ -439,6 +462,34 @@ export const executorLayer: Layer.Layer< ); } }, + compensateDeadLetter: (effect, error) => { + switch (effect.request.type) { + case "provider-turn.start": + case "provider-turn.restart": + case "provider-turn.repair": + return providerTurnStart + .failFromDeadLetter({ + threadId: effect.threadId, + runId: effect.request.runId, + error, + ...(effect.request.type === "provider-turn.repair" + ? { expectedAttemptId: effect.request.attemptId } + : {}), + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause, + }), + ), + ); + default: + return Effect.void; + } + }, }); }), ); @@ -511,7 +562,10 @@ export const layerWithOptions = ( effectId: effect.id, workerId, error: `Worker failed before settling the claimed effect: ${Cause.pretty(cause)}`, - delayMs: 0, + delayMs: + effect.request.type === "provider-turn.repair" + ? Math.min(30_000, 100 * 2 ** Math.max(0, effect.attemptCount - 1)) + : 0, }) .pipe( Effect.flatMap((requeued) => @@ -581,6 +635,31 @@ export const layerWithOptions = ( ? requeueClaim(effect, cause) : terminalizeClaim(effect, cause); + const settleRepair = (effect: EffectOutbox.OrchestrationEffectV2, error: string) => + Effect.gen(function* () { + if ( + effect.request.type === "provider-turn.start" || + effect.request.type === "provider-turn.restart" + ) { + // A crash before this marker commits still loses repair intent during process recovery. + const owned = yield* outbox.beginRepair({ + effectId: effect.id, + workerId, + runId: effect.request.runId, + error, + }); + if (!owned) return false; + const stored = yield* outbox.get(effect.id); + if (Option.isNone(stored)) return false; + effect = stored.value; + } + yield* executor.compensateDeadLetter(effect, error); + return yield* outbox.fail({ effectId: effect.id, workerId, error }); + }).pipe( + Effect.onError((cause) => requeueClaim(effect, cause)), + Effect.uninterruptible, + ); + const runOnce = (excludeRestartContinuations = false) => Effect.gen(function* () { const claimExit = yield* Effect.exit( @@ -631,6 +710,25 @@ export const layerWithOptions = ( }).pipe(Effect.onError((cause) => requeueClaim(effect, cause))); if (cancelledBeforeExecution) return true; + if ( + effect.request.type === "provider-turn.repair" || + (effect.attemptCount > maxAttempts && + (effect.request.type === "provider-turn.start" || + effect.request.type === "provider-turn.restart")) + ) { + const error = + effect.request.type === "provider-turn.repair" + ? effect.request.error + : "Provider startup retry budget exhausted."; + const settled = yield* settleRepair(effect, error); + if (!settled && !(yield* wasCancelled(effect.id))) + return yield* new OrchestrationEffectWorkerError({ + operation: "repair", + effectId: effect.id, + cause: "The worker no longer owns the effect lease.", + }); + return true; + } const execution = executor .execute(effect, { willRetry: effect.attemptCount < maxAttempts }) .pipe(Effect.as("executed" as const)); @@ -666,22 +764,32 @@ export const layerWithOptions = ( }); // Prefer succeed for terminal interrupt races so the outbox does not // keep a failed interrupt around; fail only when we must not retry. - const updated = nonRetryable - ? yield* outbox - .succeed({ effectId: effect.id, workerId }) - .pipe(Effect.onError((cause) => terminalizeClaim(effect, cause))) - : effect.attemptCount >= maxAttempts - ? yield* outbox - .fail({ effectId: effect.id, workerId, error }) - .pipe(Effect.onError((cause) => terminalizeClaim(effect, cause))) - : yield* outbox - .retry({ - effectId: effect.id, - workerId, - error, - delayMs: Math.min(30_000, 100 * 2 ** Math.max(0, effect.attemptCount - 1)), - }) - .pipe(Effect.onError((cause) => requeueClaim(effect, cause))); + let updated: boolean; + if (nonRetryable) { + updated = yield* outbox + .succeed({ effectId: effect.id, workerId }) + .pipe(Effect.onError((cause) => terminalizeClaim(effect, cause))); + } else if (effect.attemptCount < maxAttempts) { + updated = yield* outbox + .retry({ + effectId: effect.id, + workerId, + error, + delayMs: Math.min(30_000, 100 * 2 ** Math.max(0, effect.attemptCount - 1)), + }) + .pipe(Effect.onError((cause) => requeueClaim(effect, cause))); + } else if ( + effect.request.type === "provider-turn.start" || + effect.request.type === "provider-turn.restart" + ) { + updated = yield* settleRepair(effect, error); + } else { + updated = yield* executor.compensateDeadLetter(effect, error).pipe( + Effect.andThen(outbox.fail({ effectId: effect.id, workerId, error })), + Effect.onError((cause) => terminalizeClaim(effect, cause)), + Effect.uninterruptible, + ); + } if (!updated) { if (yield* wasCancelled(effect.id)) return true; return yield* new OrchestrationEffectWorkerError({ diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index b7f6e642cffe..24f0aeb5f6b7 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -20,8 +20,10 @@ import { ProviderInstanceId, ProviderSessionId, ProviderThreadId, + ProviderTurnId, RunAttemptId, RunId, + RuntimeRequestId, ThreadId, TurnItemId, } from "@t3tools/contracts"; @@ -30,6 +32,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Scheduler from "effect/Scheduler"; @@ -43,6 +46,11 @@ import * as Statement from "effect/unstable/sql/Statement"; import { LIVE_STREAM_MAX_ITEMS, LiveStreamBufferError } from "./LiveStreamBudget.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; +import * as ContextHandoffService from "./ContextHandoffService.ts"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderAuthService from "../provider/Services/ProviderAuthService.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as EffectWorker from "./EffectWorker.ts"; @@ -51,7 +59,24 @@ import * as EventStore from "./EventStore.ts"; import * as IdAllocator from "./IdAllocator.ts"; import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProviderAdapter from "./ProviderAdapter.ts"; import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; +import * as ProviderSessionManager from "./ProviderSessionManager.ts"; +import * as ProviderTurnControlService from "./ProviderTurnControlService.ts"; +import * as ProviderTurnStartService from "./ProviderTurnStartService.ts"; +import * as RunExecutionService from "./RunExecutionService.ts"; +import * as RunFinalizationService from "./RunFinalizationService.ts"; +import * as RuntimePolicy from "./RuntimePolicy.ts"; +import * as RuntimeRequestService from "./RuntimeRequestService.ts"; +import * as ThreadTitleRegenerationService from "./ThreadTitleRegenerationService.ts"; +import * as ThreadManagementService from "./ThreadManagementService.ts"; + +const deadLetterStartupDependencies = Layer.mergeAll( + FileSystem.layerNoop({}), + Layer.mock(GitWorkflowService.GitWorkflowService)({}), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProviderAuthService.ProviderAuthService)({}), +); const isLiveStreamBufferError = Schema.is(LiveStreamBufferError); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -1446,6 +1471,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { EffectWorker.OrchestrationEffectExecutorV2, EffectWorker.OrchestrationEffectExecutorV2.of({ execute: () => Ref.update(executionCount, (count) => count + 1), + compensateDeadLetter: () => Effect.void, }), ); const workerLayer = EffectWorker.layerWithOptions({ workerId: "recovery-worker" }).pipe( @@ -1865,6 +1891,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { Deferred.succeed(interrupted, undefined).pipe(Effect.ignore), ), ), + compensateDeadLetter: () => Effect.void, }), ); const workerLayer = EffectWorker.layerWithOptions({ @@ -1935,7 +1962,10 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { }); const executorLayer = Layer.succeed( EffectWorker.OrchestrationEffectExecutorV2, - EffectWorker.OrchestrationEffectExecutorV2.of({ execute: () => Effect.void }), + EffectWorker.OrchestrationEffectExecutorV2.of({ + execute: () => Effect.void, + compensateDeadLetter: () => Effect.void, + }), ); const workerLayer = EffectWorker.layerWithOptions({ workerId: "settlement-race-worker", @@ -1991,6 +2021,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { EffectWorker.OrchestrationEffectExecutorV2, EffectWorker.OrchestrationEffectExecutorV2.of({ execute: () => Ref.update(executionCount, (count) => count + 1), + compensateDeadLetter: () => Effect.void, }), ); const workerLayer = EffectWorker.layerWithOptions({ @@ -2326,6 +2357,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { } yield* Deferred.succeed(completed, undefined); }), + compensateDeadLetter: () => Effect.void, }), ); const workerLayer = EffectWorker.layerWithOptions({ @@ -2400,6 +2432,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { Effect.andThen(Deferred.await(gate.release)), ); }, + compensateDeadLetter: () => Effect.void, }), ); const workerLayer = EffectWorker.layerWithOptions({ @@ -2503,6 +2536,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { : Effect.void, ), ), + compensateDeadLetter: () => Effect.void, }), ); const workerLayer = EffectWorker.layerWithOptions({ @@ -3179,6 +3213,1500 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { assert.lengthOf((yield* projectionStore.getThreadProjection(threadId)).turnItems, 151); }), ); + + for (const failureMode of [ + "start-dead-letter", + "restart-dead-letter", + "repair-read-retry", + "repair-write-retry", + "repair-outbox-retry", + "repair-lease-reclaim", + "restart-open-failure", + "restart-open-retry-exhaustion", + "restart-thread-load-failure", + ] as const) { + const effectType = + failureMode === "start-dead-letter" ? "provider-turn.start" : "provider-turn.restart"; + const eagerOpenFailure = + failureMode === "restart-open-failure" || failureMode === "restart-open-retry-exhaustion"; + // The restart's session opens, but loading its native thread fails for good. + const threadLoadFailure = failureMode === "restart-thread-load-failure"; + const settlesInStart = eagerOpenFailure || threadLoadFailure; + it.effect(`settles inherited work after ${failureMode}`, () => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:foundation-dead-letter-run-failure"); + const runId = RunId.make("run:foundation-dead-letter-run-failure"); + const attemptId = RunAttemptId.make("run-attempt:foundation-dead-letter-run-failure"); + const rootNodeId = NodeId.make("node:foundation-dead-letter-run-failure"); + const providerThreadId = ProviderThreadId.make( + "provider-thread:foundation-dead-letter-run-failure", + ); + const subagentNodeId = NodeId.make("node:foundation-dead-letter-run-failure:subagent"); + const openItemId = TurnItemId.make("turn-item:foundation-dead-letter-run-failure:open"); + const streamingMessageId = MessageId.make( + "message:foundation-dead-letter-run-failure:streaming", + ); + const parentRequestId = RuntimeRequestId.make( + "runtime-request:foundation-dead-letter-run-failure:parent", + ); + // Child A: provider-native linked child (no runs of its own, rows carry + // runId null) that itself links grandchild C. Child B: app-owned + // delegation with its own live run, which the sweep must not touch. + const childAThreadId = ThreadId.make("thread:foundation-dead-letter:child-a"); + const childBThreadId = ThreadId.make("thread:foundation-dead-letter:child-b"); + const childCThreadId = ThreadId.make("thread:foundation-dead-letter:child-c"); + const subagentBNodeId = NodeId.make("node:foundation-dead-letter-run-failure:subagent-b"); + const childANodeId = NodeId.make("node:foundation-dead-letter:child-a:root"); + const childASubagentNodeId = NodeId.make("node:foundation-dead-letter:child-a:subagent"); + const childAItemId = TurnItemId.make("turn-item:foundation-dead-letter:child-a:open"); + const childAMessageId = MessageId.make("message:foundation-dead-letter:child-a:streaming"); + const childARequestId = RuntimeRequestId.make( + "runtime-request:foundation-dead-letter:child-a", + ); + const childAProviderTurnId = ProviderTurnId.make( + "provider-turn:foundation-dead-letter:child-a", + ); + const childAProviderThreadId = ProviderThreadId.make( + "provider-thread:foundation-dead-letter:child-a", + ); + const childCItemId = TurnItemId.make("turn-item:foundation-dead-letter:child-c:open"); + const childBRunId = RunId.make("run:foundation-dead-letter:child-b"); + const childBAttemptId = RunAttemptId.make("run-attempt:foundation-dead-letter:child-b"); + const childBNodeId = NodeId.make("node:foundation-dead-letter:child-b:root"); + const childBItemId = TurnItemId.make("turn-item:foundation-dead-letter:child-b:open"); + const childBRequestId = RuntimeRequestId.make( + "runtime-request:foundation-dead-letter:child-b", + ); + const childBProviderTurnId = ProviderTurnId.make( + "provider-turn:foundation-dead-letter:child-b", + ); + const childBProviderThreadId = ProviderThreadId.make( + "provider-thread:foundation-dead-letter:child-b", + ); + const thread = makeThread(threadId, now); + const startingRun: OrchestrationV2Run = { + id: runId, + threadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId, + userMessageId: MessageId.make("message:foundation-dead-letter-run-failure"), + rootNodeId, + activeAttemptId: attemptId, + status: "starting", + queuePosition: null, + requestedAt: now, + startedAt: null, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }; + yield* eventSink.write({ + events: [ + threadCreatedEvent({ id: "event:foundation-dead-letter:thread", thread, now }), + { + id: EventId.make("event:foundation-dead-letter:provider-thread"), + type: "provider-thread.updated", + threadId, + driver: providerDriver, + providerInstanceId, + occurredAt: now, + payload: { + id: providerThreadId, + driver: providerDriver, + providerInstanceId, + providerSessionId: null, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "active", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:run"), + type: "run.created", + threadId, + runId, + nodeId: rootNodeId, + providerInstanceId, + occurredAt: now, + payload: startingRun, + }, + { + id: EventId.make("event:foundation-dead-letter:attempt"), + type: "run-attempt.created", + threadId, + runId, + nodeId: rootNodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: attemptId, + runId, + attemptOrdinal: 1, + rootNodeId, + providerInstanceId, + providerThreadId, + providerTurnId: null, + reason: "initial", + status: "pending", + startedAt: null, + completedAt: null, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:node"), + type: "node.updated", + threadId, + runId, + nodeId: rootNodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: rootNodeId, + threadId, + runId, + parentNodeId: null, + rootNodeId, + kind: "root_turn", + status: "pending", + countsForRun: true, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: null, + completedAt: null, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:parent-request"), + type: "runtime-request.updated", + threadId, + runId, + nodeId: rootNodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: parentRequestId, + nodeId: rootNodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "command", + status: "pending", + responseCapability: { + type: "live", + providerSessionId: ProviderSessionId.make( + "provider-session:foundation-dead-letter:parent-request", + ), + }, + createdAt: now, + resolvedAt: null, + }, + }, + // Open run-owned work inherited from an interrupted attempt: the + // compensation must cascade these instead of stranding them under + // the failed run. + { + id: EventId.make("event:foundation-dead-letter:subagent-node"), + type: "node.updated", + threadId, + runId, + nodeId: subagentNodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: subagentNodeId, + threadId, + runId, + parentNodeId: rootNodeId, + rootNodeId, + kind: "subagent", + status: "running", + countsForRun: false, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:subagent"), + type: "subagent.updated", + threadId, + runId, + nodeId: subagentNodeId, + driver: providerDriver, + providerInstanceId, + occurredAt: now, + payload: { + id: subagentNodeId, + threadId, + runId, + parentNodeId: rootNodeId, + origin: "app_owned", + createdBy: "agent", + driver: providerDriver, + providerInstanceId, + providerThreadId, + childThreadId: childAThreadId, + nativeTaskRef: null, + prompt: "child task", + title: null, + model: null, + // The lifetime link can settle before its routed child rows. + status: "completed", + result: null, + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:open-item"), + type: "turn-item.updated", + threadId, + runId, + nodeId: subagentNodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: openItemId, + threadId, + runId, + nodeId: subagentNodeId, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running", + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "assistant_message", + messageId: streamingMessageId, + text: "partial", + streaming: true, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:streaming-message"), + type: "message.updated", + threadId, + runId, + occurredAt: now, + payload: { + createdBy: "agent", + creationSource: "provider", + id: streamingMessageId, + threadId, + runId, + nodeId: subagentNodeId, + role: "assistant", + text: "partial", + attachments: [], + streaming: true, + createdAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:subagent-b"), + type: "subagent.updated", + threadId, + runId, + nodeId: subagentBNodeId, + driver: providerDriver, + providerInstanceId, + occurredAt: now, + payload: { + id: subagentBNodeId, + threadId, + runId, + parentNodeId: rootNodeId, + origin: "app_owned", + createdBy: "agent", + driver: providerDriver, + providerInstanceId, + providerThreadId: null, + childThreadId: childBThreadId, + nativeTaskRef: null, + prompt: "delegated task", + title: null, + model: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }, + threadCreatedEvent({ + id: "event:foundation-dead-letter:child-a:thread", + thread: makeThread(childAThreadId, now), + now, + }), + { + id: EventId.make("event:foundation-dead-letter:child-a:node"), + type: "node.updated", + threadId: childAThreadId, + nodeId: childANodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: childANodeId, + threadId: childAThreadId, + runId: null, + parentNodeId: null, + rootNodeId: childANodeId, + kind: "root_turn", + status: "running", + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:child-a:provider-turn"), + type: "provider-turn.updated", + threadId: childAThreadId, + nodeId: childANodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: childAProviderTurnId, + providerThreadId: childAProviderThreadId, + nodeId: childANodeId, + runAttemptId: null, + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:child-a:request"), + type: "runtime-request.updated", + threadId: childAThreadId, + nodeId: childANodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: childARequestId, + nodeId: childANodeId, + providerTurnId: childAProviderTurnId, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { + type: "live", + providerSessionId: ProviderSessionId.make( + "provider-session:foundation-dead-letter:child-a-request", + ), + }, + createdAt: now, + resolvedAt: null, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:child-a:item"), + type: "turn-item.updated", + threadId: childAThreadId, + nodeId: childANodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: childAItemId, + threadId: childAThreadId, + runId: null, + nodeId: childANodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running", + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "assistant_message", + messageId: childAMessageId, + text: "child partial", + streaming: true, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:child-a:message"), + type: "message.updated", + threadId: childAThreadId, + occurredAt: now, + payload: { + createdBy: "agent", + creationSource: "provider", + id: childAMessageId, + threadId: childAThreadId, + runId: null, + nodeId: childANodeId, + role: "assistant", + text: "child partial", + attachments: [], + streaming: true, + createdAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:child-a:subagent"), + type: "subagent.updated", + threadId: childAThreadId, + nodeId: childASubagentNodeId, + driver: providerDriver, + providerInstanceId, + occurredAt: now, + payload: { + id: childASubagentNodeId, + threadId: childAThreadId, + runId: null, + parentNodeId: childANodeId, + origin: "provider_native", + createdBy: "agent", + driver: providerDriver, + providerInstanceId, + providerThreadId: null, + childThreadId: childCThreadId, + nativeTaskRef: null, + prompt: "grandchild task", + title: null, + model: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }, + threadCreatedEvent({ + id: "event:foundation-dead-letter:child-c:thread", + thread: makeThread(childCThreadId, now), + now, + }), + { + id: EventId.make("event:foundation-dead-letter:child-c:item"), + type: "turn-item.updated", + threadId: childCThreadId, + providerInstanceId, + occurredAt: now, + payload: { + id: childCItemId, + threadId: childCThreadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running", + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "reasoning", + text: "grandchild partial", + streaming: false, + }, + }, + threadCreatedEvent({ + id: "event:foundation-dead-letter:child-b:thread", + thread: makeThread(childBThreadId, now), + now, + }), + { + id: EventId.make("event:foundation-dead-letter:child-b:run"), + type: "run.created", + threadId: childBThreadId, + runId: childBRunId, + providerInstanceId, + occurredAt: now, + payload: { + id: childBRunId, + threadId: childBThreadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId: childBProviderThreadId, + userMessageId: MessageId.make("message:foundation-dead-letter:child-b:user"), + rootNodeId: childBNodeId, + activeAttemptId: childBAttemptId, + status: "running", + queuePosition: null, + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:child-b:attempt"), + type: "run-attempt.created", + threadId: childBThreadId, + runId: childBRunId, + nodeId: childBNodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: childBAttemptId, + runId: childBRunId, + attemptOrdinal: 1, + rootNodeId: childBNodeId, + providerInstanceId, + providerThreadId: childBProviderThreadId, + providerTurnId: childBProviderTurnId, + reason: "initial", + status: "running", + startedAt: now, + completedAt: null, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:child-b:node"), + type: "node.updated", + threadId: childBThreadId, + runId: childBRunId, + nodeId: childBNodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: childBNodeId, + threadId: childBThreadId, + runId: childBRunId, + parentNodeId: null, + rootNodeId: childBNodeId, + kind: "root_turn", + status: "running", + countsForRun: true, + providerThreadId: childBProviderThreadId, + providerTurnId: childBProviderTurnId, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:child-b:provider-turn"), + type: "provider-turn.updated", + threadId: childBThreadId, + runId: childBRunId, + nodeId: childBNodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: childBProviderTurnId, + providerThreadId: childBProviderThreadId, + nodeId: childBNodeId, + runAttemptId: childBAttemptId, + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:child-b:request"), + type: "runtime-request.updated", + threadId: childBThreadId, + runId: childBRunId, + nodeId: childBNodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: childBRequestId, + nodeId: childBNodeId, + providerTurnId: childBProviderTurnId, + nativeRequestRef: null, + kind: "command", + status: "pending", + responseCapability: { + type: "live", + providerSessionId: ProviderSessionId.make( + "provider-session:foundation-dead-letter:child-b-request", + ), + }, + createdAt: now, + resolvedAt: null, + }, + }, + { + id: EventId.make("event:foundation-dead-letter:child-b:item"), + type: "turn-item.updated", + threadId: childBThreadId, + runId: childBRunId, + nodeId: childBNodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: childBItemId, + threadId: childBThreadId, + runId: childBRunId, + nodeId: childBNodeId, + providerThreadId: childBProviderThreadId, + providerTurnId: childBProviderTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running", + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "reasoning", + text: "delegated child working", + streaming: false, + }, + }, + ], + }); + + const liveChild = yield* projectionStore.getThreadProjection(childBThreadId); + const runlessNodeId = NodeId.make("node:live-child:session-request"); + const runlessRequestId = RuntimeRequestId.make("request:live-child:session-request"); + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ threadId: childBThreadId }), + type: "node.updated", + threadId: childBThreadId, + occurredAt: now, + payload: { + ...liveChild.nodes[0]!, + id: runlessNodeId, + runId: null, + kind: "approval_request", + countsForRun: false, + runtimeRequestId: runlessRequestId, + }, + }, + { + id: yield* idAllocator.allocate.event({ threadId: childBThreadId }), + type: "runtime-request.updated", + threadId: childBThreadId, + occurredAt: now, + payload: { + ...liveChild.runtimeRequests[0]!, + id: runlessRequestId, + nodeId: runlessNodeId, + providerTurnId: null, + }, + }, + ], + }); + + if (settlesInStart) { + const seeded = yield* projectionStore.getThreadProjection(threadId); + const scopeId = CheckpointScopeId.make("checkpoint-scope:failed-restart"); + const payloads = [ + { + type: "provider-thread.updated", + payload: { + ...seeded.providerThreads[0]!, + providerSessionId: ProviderSessionId.make("failed-restart-session"), + }, + }, + { + type: "run-attempt.updated", + payload: { ...seeded.attempts[0]!, attemptOrdinal: 2, reason: "steering_restart" }, + }, + { + type: "node.updated", + payload: { + ...seeded.nodes.find((node) => node.id === rootNodeId)!, + checkpointScopeId: scopeId, + }, + }, + { + type: "checkpoint-scope.created", + payload: { + id: scopeId, + threadId, + runId, + nodeId: rootNodeId, + parentScopeId: null, + providerThreadId, + kind: "root_run", + ordinalWithinParent: 0, + advancesAppRunCount: true, + cwd: "/workspace", + createdAt: now, + }, + }, + { + type: "message.updated", + payload: { + id: startingRun.userMessageId, + threadId, + runId, + nodeId: rootNodeId, + ordinal: 100, + role: "user", + text: "Continue", + attachments: [], + streaming: false, + createdBy: "user", + creationSource: "web", + createdAt: now, + updatedAt: now, + }, + }, + ] as const; + yield* eventSink.write({ + events: yield* Effect.forEach( + payloads, + Effect.fnUntraced(function* (event) { + return { + ...event, + id: yield* idAllocator.allocate.event({ threadId }), + threadId, + runId, + occurredAt: now, + }; + }), + ), + }); + } + const openCalls = yield* Ref.make(0); + const threadLoadError = new ProviderAdapter.ProviderAdapterProtocolError({ + driver: providerDriver, + detail: "Restart thread unavailable", + }); + const openedSession = { + driver: providerDriver, + resumeThread: () => Effect.fail(threadLoadError), + ensureThread: () => Effect.fail(threadLoadError), + forkThread: () => Effect.fail(threadLoadError), + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime; + const sessionManager = Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + open: () => + Ref.update(openCalls, (count) => count + 1).pipe( + Effect.andThen( + threadLoadFailure + ? Effect.succeed(openedSession) + : Effect.fail( + new ProviderSessionManager.ProviderSessionOpenError({ + providerSessionId: ProviderSessionId.make("failed-restart-session"), + instanceId: providerInstanceId, + cause: "Restart session unavailable", + }), + ), + ), + ), + }); + const effectId = "effect:foundation-dead-letter-run-failure"; + yield* outbox.enqueue([ + { + id: effectId, + commandId: CommandId.make("command:foundation-dead-letter-run-failure"), + threadId, + request: + effectType === "provider-turn.start" + ? { type: effectType, runId } + : { + type: effectType, + runId, + providerThreadId, + providerSessionId: ProviderSessionId.make("dead-letter:restart-session"), + providerTurnId: ProviderTurnId.make("dead-letter:previous-turn"), + interruptedAttemptId: RunAttemptId.make("dead-letter:previous-attempt"), + }, + }, + ]); + const beforeDeadLetter = yield* projectionStore.getThreadProjection(threadId); + + const childReadEntered = yield* Deferred.make(); + const resumeChildRead = yield* Deferred.make(); + let injected = false; + const repairingStore = ProjectionStore.ProjectionStoreV2.of({ + ...projectionStore, + getThreadProjection: (id) => + Effect.gen(function* () { + if (id === childAThreadId && !injected) { + if (failureMode === "repair-read-retry") { + injected = true; + return yield* new ProjectionStore.ProjectionStoreReadError({ threadId: id }); + } + if (failureMode === "repair-lease-reclaim") { + injected = true; + yield* Deferred.succeed(childReadEntered, undefined); + yield* Deferred.await(resumeChildRead); + } + } + return yield* projectionStore.getThreadProjection(id); + }), + }); + const repairingSink = EventSink.EventSinkV2.of({ + ...eventSink, + writeIfRunCurrent: (input) => + Effect.gen(function* () { + if (failureMode === "repair-write-retry" && !injected) { + injected = true; + return yield* new EventSink.EventSinkWriteError({ + eventCount: input.events.length, + }); + } + return yield* eventSink.writeIfRunCurrent(input); + }), + }); + // Missing bindings exhaust the worker. A valid restart binding reaches + // open and settles immediately, so its worker succeeds without compensation. + const providerTurnStartLive = ProviderTurnStartService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(EventSink.EventSinkV2, repairingSink), + Layer.succeed(ProjectionStore.ProjectionStoreV2, repairingStore), + deadLetterStartupDependencies, + Layer.succeed(IdAllocator.IdAllocatorV2, idAllocator), + Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}), + sessionManager, + Layer.mock(RunExecutionService.RunExecutionServiceV2)({}), + RuntimePolicy.layer, + ), + ), + ); + const executorProvided = EffectWorker.executorLayer.pipe( + Layer.provide( + Layer.mergeAll( + providerTurnStartLive, + Layer.mock(ProviderTurnControlService.ProviderTurnControlServiceV2)({ + interruptAndAwaitTerminal: () => Effect.void, + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({}), + Layer.mock(CheckpointRollbackService.CheckpointRollbackServiceV2)({}), + Layer.mock(RuntimeRequestService.RuntimeRequestServiceV2)({}), + Layer.mock(RunFinalizationService.RunFinalizationService)({}), + Layer.mock(ThreadTitleRegenerationService.ThreadTitleRegenerationService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + ServerSettings.layerTest(), + ), + ), + ); + const executions = yield* Ref.make(0); + const countedExecutor = Layer.effect( + EffectWorker.OrchestrationEffectExecutorV2, + Effect.gen(function* () { + const delegate = yield* EffectWorker.OrchestrationEffectExecutorV2; + return EffectWorker.OrchestrationEffectExecutorV2.of({ + ...delegate, + execute: (effect, options) => + Ref.update(executions, (n) => n + 1).pipe( + Effect.andThen(delegate.execute(effect, options)), + ), + }); + }), + ).pipe(Layer.provide(executorProvided)); + const repairingOutbox = EffectOutbox.EffectOutboxV2.of({ + ...outbox, + fail: (input) => + Effect.gen(function* () { + if (failureMode === "repair-outbox-retry" && !injected) { + injected = true; + return yield* new EffectOutbox.EffectOutboxError({ + operation: "fail", + effectId: input.effectId, + }); + } + return yield* outbox.fail(input); + }), + }); + const workerLayer = (workerId: string) => + EffectWorker.layerWithOptions({ + workerId, + maxAttempts: failureMode === "restart-open-retry-exhaustion" ? 2 : 1, + leaseDurationMs: 1000, + }).pipe( + Layer.provide( + Layer.merge( + Layer.succeed(EffectOutbox.EffectOutboxV2, repairingOutbox), + countedExecutor, + ), + ), + ); + const runWorker = (workerId: string) => + EffectWorker.OrchestrationEffectWorkerV2.pipe( + Effect.flatMap((worker) => worker.runOnce), + Effect.provide(workerLayer(workerId)), + ); + if (failureMode === "restart-open-retry-exhaustion") { + assert.isTrue(yield* runWorker("first")); + const retrying = yield* projectionStore.getThreadProjection(threadId); + assert.equal(retrying.runs[0]?.status, "starting"); + assert.isFalse(retrying.turnItems.some((item) => item.type === "error")); + assert.equal( + (yield* projectionStore.getThreadProjection(childAThreadId)).nodes[0]?.status, + "running", + ); + yield* TestClock.adjust("100 millis"); + assert.isTrue(yield* runWorker("last")); + } else if (failureMode === "repair-lease-reclaim") { + const first = yield* runWorker("first").pipe(Effect.result, Effect.forkChild); + yield* Deferred.await(childReadEntered); + yield* TestClock.adjust("2 seconds"); + // Expiry alone cannot reclaim: SQL only claims pending rows. + assert.isFalse(yield* runWorker("second")); + const recovery = yield* outbox.reconcileAfterProcessLoss; + assert.equal(recovery.requeued, 1); + assert.equal(recovery.cancelled, 0); + const second = yield* runWorker("second").pipe( + Effect.ensuring(Deferred.succeed(resumeChildRead, undefined)), + ); + assert.isTrue(second); + const settledSequence = yield* eventSink.latestSequence(); + const firstResult = yield* Fiber.join(first); + assert.equal(firstResult._tag, "Failure"); + assert.equal(yield* eventSink.latestSequence(), settledSequence); + } else if ( + failureMode === "repair-read-retry" || + failureMode === "repair-write-retry" || + failureMode === "repair-outbox-retry" + ) { + const first = yield* runWorker("first").pipe(Effect.result); + assert.equal(first._tag, "Failure"); + assert.equal( + (yield* projectionStore.getThreadProjection(threadId)).runs[0]?.status, + failureMode === "repair-outbox-retry" ? "failed" : "starting", + ); + const pending = yield* outbox.get(effectId); + assert.isTrue(Option.isSome(pending)); + if (Option.isSome(pending)) assert.equal(pending.value.status, "pending"); + const recovery = yield* outbox.reconcileAfterProcessLoss; + assert.equal(recovery.cancelled, 0); + // New worker/service scopes over the same SQL state emulate reconstruction. + assert.isFalse(yield* runWorker("restarted")); + yield* TestClock.adjust("100 millis"); + assert.isTrue(yield* runWorker("restarted")); + } else { + assert.isTrue(yield* runWorker("first")); + } + assert.equal( + yield* Ref.get(executions), + failureMode === "restart-open-retry-exhaustion" ? 2 : 1, + ); + + const storedEffect = yield* outbox.get(effectId); + assert.isTrue(Option.isSome(storedEffect)); + if (Option.isSome(storedEffect)) { + assert.equal( + storedEffect.value.status, + settlesInStart ? "succeeded" : "failed", + storedEffect.value.lastError ?? undefined, + ); + } + const expectedOpenCalls = + failureMode === "restart-open-retry-exhaustion" ? 2 : Number(settlesInStart); + assert.equal(yield* Ref.get(openCalls), expectedOpenCalls); + const projection = yield* projectionStore.getThreadProjection(threadId); + const run = projection.runs.find((candidate) => candidate.id === runId); + assert.equal(run?.status, "failed"); + assert.isTrue(run !== undefined && run.completedAt !== null); + assert.equal( + projection.attempts.find((candidate) => candidate.id === attemptId)?.status, + "failed", + ); + assert.equal( + projection.nodes.find((candidate) => candidate.id === rootNodeId)?.status, + "failed", + ); + const parentRequest = projection.runtimeRequests.find( + (candidate) => candidate.id === parentRequestId, + ); + assert.equal(parentRequest?.status, "cancelled"); + assert.equal(parentRequest?.responseCapability.type, "not_resumable"); + assert.isNotNull(parentRequest?.resolvedAt); + assert.equal( + (yield* projectionStore.getThreadShell(threadId))?.pendingRuntimeRequest, + null, + ); + const errorItem = projection.turnItems.find((item) => item.type === "error"); + assert.isDefined(errorItem); + if (errorItem?.type === "error") { + assert.equal(errorItem.status, "failed"); + assert.include( + errorItem.failure.message, + eagerOpenFailure + ? "Restart session unavailable" + : threadLoadFailure + ? "Restart thread unavailable" + : "Starting the provider turn failed permanently", + ); + } + assert.equal( + projection.subagents.find((candidate) => candidate.id === subagentNodeId)?.status, + "completed", + ); + assert.equal( + projection.nodes.find((candidate) => candidate.id === subagentNodeId)?.status, + "failed", + ); + const parentOpenItem = projection.turnItems.find((item) => item.id === openItemId); + assert.equal(parentOpenItem?.status, "failed"); + assert.isTrue( + parentOpenItem?.type === "assistant_message" && parentOpenItem.streaming === false, + ); + assert.equal( + projection.messages.find((message) => message.id === streamingMessageId)?.streaming, + false, + ); + assert.equal( + projection.providerThreads.find((candidate) => candidate.id === providerThreadId)?.status, + "idle", + ); + const terminalStatuses: ReadonlySet = new Set([ + "cancelled", + "completed", + "failed", + "interrupted", + ]); + assert.isTrue(projection.runs.every((candidate) => terminalStatuses.has(candidate.status))); + + // Linked child threads: provider-native child A (runId-null rows) and + // its nested grandchild C are swept; app-owned child B with its own + // live run is left alone. + const childA = yield* projectionStore.getThreadProjection(childAThreadId); + assert.equal(childA.nodes.find((node) => node.id === childANodeId)?.status, "failed"); + const childAItem = childA.turnItems.find((item) => item.id === childAItemId); + assert.equal(childAItem?.status, "failed"); + assert.isTrue(childAItem?.type === "assistant_message" && childAItem.streaming === false); + assert.equal( + childA.subagents.find((candidate) => candidate.id === childASubagentNodeId)?.status, + "failed", + ); + assert.equal( + childA.messages.find((message) => message.id === childAMessageId)?.streaming, + false, + ); + const childARequest = childA.runtimeRequests.find( + (candidate) => candidate.id === childARequestId, + ); + assert.equal(childARequest?.status, "cancelled"); + assert.equal(childARequest?.responseCapability.type, "not_resumable"); + assert.isNotNull(childARequest?.resolvedAt); + assert.equal( + childA.providerTurns.find((candidate) => candidate.id === childAProviderTurnId)?.status, + "cancelled", + ); + assert.equal( + (yield* projectionStore.getThreadShell(childAThreadId))?.pendingRuntimeRequest, + null, + ); + const childC = yield* projectionStore.getThreadProjection(childCThreadId); + assert.equal(childC.turnItems.find((item) => item.id === childCItemId)?.status, "failed"); + const childB = yield* projectionStore.getThreadProjection(childBThreadId); + assert.equal(childB.nodes.find((node) => node.id === runlessNodeId)?.status, "running"); + assert.equal( + childB.runtimeRequests.find((request) => request.id === runlessRequestId)?.status, + "pending", + ); + assert.equal( + childB.runs.find((candidate) => candidate.id === childBRunId)?.status, + "running", + ); + assert.equal( + childB.nodes.find((candidate) => candidate.id === childBNodeId)?.status, + "running", + ); + assert.equal( + childB.attempts.find((candidate) => candidate.id === childBAttemptId)?.status, + "running", + ); + assert.equal(childB.turnItems.find((item) => item.id === childBItemId)?.status, "running"); + assert.equal( + childB.providerTurns.find((candidate) => candidate.id === childBProviderTurnId)?.status, + "running", + ); + assert.equal( + childB.runtimeRequests.find((candidate) => candidate.id === childBRequestId)?.status, + "pending", + ); + assert.equal( + (yield* projectionStore.getThreadShell(childBThreadId))?.pendingRuntimeRequest?.id, + childBRequestId, + ); + assert.equal( + projection.subagents.find((candidate) => candidate.id === subagentBNodeId)?.status, + "failed", + ); + + // A replayed compensation is a no-op once the run left `starting`: the + // writeIfRunCurrent guard rejects the write instead of duplicating + // terminal events. + yield* ProviderTurnStartService.ProviderTurnStartServiceV2.pipe( + Effect.flatMap((service) => + service.failFromDeadLetter({ threadId, runId, error: "replayed dead letter" }), + ), + Effect.provide(providerTurnStartLive), + ); + const replayed = yield* projectionStore.getThreadProjection(threadId); + assert.lengthOf( + replayed.turnItems.filter((item) => item.type === "error"), + 1, + ); + assert.equal( + DateTime.formatIso(replayed.runs[0]!.completedAt!), + DateTime.formatIso(run!.completedAt!), + ); + // A compensation that read the old attempt must not fail a replacement + // which reached starting before the guarded write. + const replacement = { + ...startingRun, + activeAttemptId: RunAttemptId.make("dead-letter:replacement"), + }; + yield* eventSink.write({ + events: [ + { + id: EventId.make("dead-letter:replacement-run"), + type: "run.updated", + threadId, + runId, + occurredAt: now, + payload: replacement, + }, + ], + }); + const beforeStaleCompensation = yield* eventSink.latestSequence(); + yield* ProviderTurnStartService.ProviderTurnStartServiceV2.pipe( + Effect.flatMap((service) => + service.failFromDeadLetter({ + threadId, + runId, + expectedAttemptId: attemptId, + error: "obsolete durable repair", + }), + ), + Effect.provide(providerTurnStartLive), + ); + assert.equal(yield* eventSink.latestSequence(), beforeStaleCompensation); + const staleSnapshotStore = ProjectionStore.ProjectionStoreV2.of({ + ...projectionStore, + getThreadProjection: (id) => + id === threadId + ? Effect.succeed(beforeDeadLetter) + : projectionStore.getThreadProjection(id), + }); + const staleStartLayer = ProviderTurnStartService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(EventSink.EventSinkV2, eventSink), + Layer.succeed(ProjectionStore.ProjectionStoreV2, staleSnapshotStore), + Layer.succeed(IdAllocator.IdAllocatorV2, idAllocator), + deadLetterStartupDependencies, + Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({}), + Layer.mock(RunExecutionService.RunExecutionServiceV2)({}), + Layer.mock(RuntimePolicy.RuntimePolicyV2)({}), + ), + ), + ); + yield* ProviderTurnStartService.ProviderTurnStartServiceV2.pipe( + Effect.flatMap((service) => + service.failFromDeadLetter({ threadId, runId, error: "stale failure" }), + ), + Effect.provide(staleStartLayer), + ); + assert.equal(yield* eventSink.latestSequence(), beforeStaleCompensation); + assert.equal( + (yield* projectionStore.getThreadProjection(threadId)).runs[0]?.activeAttemptId, + replacement.activeAttemptId, + ); + }).pipe(Effect.provide(Layer.fresh(TestLayer))), + ); + } + + it.effect("retries failed child reads but skips a truly missing child", () => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:foundation-dead-letter-child-read-failure"); + const runId = RunId.make("run:foundation-dead-letter-child-read-failure"); + const attemptId = RunAttemptId.make("run-attempt:foundation-dead-letter-child-read-failure"); + const rootNodeId = NodeId.make("node:foundation-dead-letter-child-read-failure"); + const subagentNodeId = NodeId.make("node:foundation-dead-letter-child-read-failure:subagent"); + const providerThreadId = ProviderThreadId.make( + "provider-thread:foundation-dead-letter-child-read-failure", + ); + const childThreadId = ThreadId.make("thread:foundation-dead-letter-child-read-failure:child"); + yield* eventSink.write({ + events: [ + threadCreatedEvent({ + id: "event:foundation-dead-letter-child-read-failure:thread", + thread: makeThread(threadId, now), + now, + }), + { + id: EventId.make("event:foundation-dead-letter-child-read-failure:provider-thread"), + type: "provider-thread.updated", + threadId, + driver: providerDriver, + providerInstanceId, + occurredAt: now, + payload: { + id: providerThreadId, + driver: providerDriver, + providerInstanceId, + providerSessionId: null, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "active", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make("event:foundation-dead-letter-child-read-failure:run"), + type: "run.created", + threadId, + runId, + nodeId: rootNodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId, + userMessageId: MessageId.make( + "message:foundation-dead-letter-child-read-failure:user", + ), + rootNodeId, + activeAttemptId: attemptId, + status: "starting", + queuePosition: null, + requestedAt: now, + startedAt: null, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + }, + { + id: EventId.make("event:foundation-dead-letter-child-read-failure:attempt"), + type: "run-attempt.created", + threadId, + runId, + nodeId: rootNodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: attemptId, + runId, + attemptOrdinal: 1, + rootNodeId, + providerInstanceId, + providerThreadId, + providerTurnId: null, + reason: "initial", + status: "pending", + startedAt: null, + completedAt: null, + }, + }, + { + id: EventId.make("event:foundation-dead-letter-child-read-failure:node"), + type: "node.updated", + threadId, + runId, + nodeId: rootNodeId, + providerInstanceId, + occurredAt: now, + payload: { + id: rootNodeId, + threadId, + runId, + parentNodeId: null, + rootNodeId, + kind: "root_turn", + status: "pending", + countsForRun: true, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: null, + completedAt: null, + }, + }, + { + id: EventId.make("event:foundation-dead-letter-child-read-failure:subagent"), + type: "subagent.updated", + threadId, + runId, + nodeId: subagentNodeId, + driver: providerDriver, + providerInstanceId, + occurredAt: now, + payload: { + id: subagentNodeId, + threadId, + runId, + parentNodeId: rootNodeId, + origin: "provider_native", + createdBy: "agent", + driver: providerDriver, + providerInstanceId, + providerThreadId, + childThreadId, + nativeTaskRef: null, + prompt: "child task", + title: null, + model: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }, + ], + }); + + let failRead = true; + const failingProjectionStore = ProjectionStore.ProjectionStoreV2.of({ + ...projectionStore, + getThreadProjection: (readThreadId) => + readThreadId === childThreadId && failRead + ? Effect.fail(new ProjectionStore.ProjectionStoreReadError({ threadId: readThreadId })) + : projectionStore.getThreadProjection(readThreadId), + }); + const providerTurnStartLive = ProviderTurnStartService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(EventSink.EventSinkV2, eventSink), + Layer.succeed(ProjectionStore.ProjectionStoreV2, failingProjectionStore), + deadLetterStartupDependencies, + Layer.succeed(IdAllocator.IdAllocatorV2, idAllocator), + Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({}), + Layer.mock(RunExecutionService.RunExecutionServiceV2)({}), + Layer.mock(RuntimePolicy.RuntimePolicyV2)({}), + ), + ), + ); + + const repair = ProviderTurnStartService.ProviderTurnStartServiceV2.pipe( + Effect.flatMap((service) => + service.failFromDeadLetter({ threadId, runId, error: "dead letter" }), + ), + Effect.provide(providerTurnStartLive), + ); + assert.equal((yield* Effect.result(repair))._tag, "Failure"); + assert.equal( + (yield* projectionStore.getThreadProjection(threadId)).runs[0]?.status, + "starting", + ); + const outbox = yield* EffectOutbox.EffectOutboxV2; + const effectId = "effect:repair-through-runtime-recovery"; + yield* outbox.enqueue([ + { + id: effectId, + commandId: CommandId.make(effectId), + threadId, + request: { type: "provider-turn.start", runId }, + }, + ]); + yield* outbox.claimNext({ workerId: "before-restart", leaseDurationMs: 1000 }); + assert.isTrue( + yield* outbox.beginRepair({ + effectId, + workerId: "before-restart", + runId, + error: "exhausted", + }), + ); + assert.deepEqual(yield* outbox.getRepairRunIds(threadId), [runId]); + const repairRun = (yield* projectionStore.getThreadProjection(threadId)).runs[0]!; + const changeAttempt = (activeAttemptId: RunAttemptId) => + eventSink.write({ + events: [ + { + id: EventId.make(`repair-attempt:${activeAttemptId}`), + type: "run.updated", + threadId, + runId, + occurredAt: now, + payload: { ...repairRun, activeAttemptId }, + }, + ], + }); + yield* changeAttempt(RunAttemptId.make("repair-attempt:newer")); + assert.deepEqual(yield* outbox.getRepairRunIds(threadId), []); + yield* changeAttempt(attemptId); + assert.deepEqual(yield* outbox.getRepairRunIds(threadId), [runId]); + const recovery = yield* ProviderRuntimeRecovery.make.pipe( + Effect.provide(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true })), + ); + const recovered = yield* recovery.recover; + assert.equal(recovered.requeuedEffects, 1); + assert.equal(recovered.terminalizedRuns, 0); + assert.equal( + (yield* projectionStore.getThreadProjection(threadId)).runs[0]?.status, + "starting", + ); + failRead = false; + yield* repair; + const projection = yield* projectionStore.getThreadProjection(threadId); + assert.equal(projection.runs.find((candidate) => candidate.id === runId)?.status, "failed"); + assert.equal( + projection.attempts.find((candidate) => candidate.id === attemptId)?.status, + "failed", + ); + assert.equal( + projection.nodes.find((candidate) => candidate.id === rootNodeId)?.status, + "failed", + ); + assert.equal( + projection.subagents.find((candidate) => candidate.id === subagentNodeId)?.status, + "failed", + ); + }).pipe(Effect.provide(Layer.fresh(TestLayer))), + ); }); it.live("keeps claiming new work after repeated idle periods", () => @@ -3194,6 +4722,7 @@ it.live("keeps claiming new work after repeated idle periods", () => ? Effect.die(`Missing completion signal for ${effect.id}`) : Deferred.succeed(completion, undefined).pipe(Effect.asVoid); }, + compensateDeadLetter: () => Effect.void, }), ); const workerLayer = EffectWorker.layerWithOptions({ diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index 8df85818a761..5f9d814019fb 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -18,6 +18,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import { restartContinuationRun } from "./RestartContinuation.ts"; + import * as EffectWorker from "./EffectWorker.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as EventSink from "./EventSink.ts"; @@ -747,6 +749,7 @@ it.effect( runRecoveryOnce: Effect.succeed(false), }), Layer.mock(EffectOutbox.EffectOutboxV2)({ + getRepairRunIds: () => Effect.succeed([]), reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), }), ), @@ -1272,3 +1275,92 @@ it.effect( }).pipe(Effect.provide(layer)); }, ); + +it.effect("leaves a repair-owned prepared continuation and its background items to repair", () => { + const threadId = ThreadId.make("repair-owned-continuation"); + const runId = RunId.make("repair-owned-continuation:run"); + const providerThreadId = ProviderThreadId.make("repair-owned-continuation:provider-thread"); + const providerSessionId = ProviderSessionId.make("repair-owned-continuation:session"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const driver = ProviderDriverKind.make("codex"); + const projection = { + thread: { id: threadId, archivedAt: null, deletedAt: null, providerInstanceId }, + runs: [ + { + id: runId, + ordinal: 2, + status: "starting", + providerInstanceId, + providerThreadId, + restartContinuationOfRunId: RunId.make("repair-owned-continuation:previous"), + }, + ], + providerThreads: [ + { + id: providerThreadId, + appThreadId: threadId, + ownerNodeId: null, + providerInstanceId, + driver, + providerSessionId, + status: "active", + nativeThreadRef: { driver, nativeId: "native:repair-owned", strength: "strong" }, + }, + ], + providerSessions: [{ id: providerSessionId, providerInstanceId, driver, status: "stopped" }], + turnItems: [ + { + id: TurnItemId.make("repair-owned-background"), + runId, + nodeId: null, + providerThreadId, + providerInstanceId, + type: "command_execution", + status: "running", + }, + ], + attempts: [], + nodes: [], + subagents: [], + messages: [], + providerTurns: [], + runtimeRequests: [], + } as unknown as OrchestrationV2ThreadProjection; + const committed: Array[0]> = []; + const layer = ProviderRuntimeRecovery.layer.pipe( + Layer.provide(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true })), + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRecoveryThreadIds: () => Effect.succeed([threadId]), + getRuntimeRecoveryProjection: () => Effect.succeed(projection), + }), + Layer.mock(EventSink.EventSinkV2)({ + commitCommand: (input) => { + committed.push(input); + return Effect.succeed({ committed: true, cancelledEffectCount: 0 } as never); + }, + }), + IdAllocator.layer, + Layer.mock(EffectOutbox.EffectOutboxV2)({ + getRepairRunIds: () => Effect.succeed([runId]), + reconcileAfterProcessLoss: Effect.succeed({ requeued: 1, cancelled: 0 }), + }), + ), + ), + ); + return Effect.gen(function* () { + assert.equal(restartContinuationRun(projection)?.id, runId); + const summary = yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).recover; + assert.equal(summary.terminalizedRuns, 0); + assert.deepEqual( + committed.flatMap((command) => command.effects ?? []), + [], + ); + assert.isFalse( + committed + .flatMap((command) => command.events) + .some((event) => event.type === "run.updated" || event.type === "turn-item.updated"), + ); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index 67ae22f1883b..c8f4ce1bb516 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -186,8 +186,25 @@ export const make = Effect.gen(function* () { continueAfterRestart: boolean, ) { const now = yield* DateTime.now; + const repairRunIds = new Set( + yield* ( + projection.runs.some((run) => run.status === "starting") + ? outbox.getRepairRunIds(projection.thread.id) + : Effect.succeed([]) + ).pipe( + Effect.mapError( + (cause) => + new ProviderRuntimeRecoveryError({ + operation: "reconcile", + threadId: projection.thread.id, + cause, + }), + ), + ), + ); const runs = [] as Array; for (const run of nonterminalRuns(projection)) { + if (repairRunIds.has(run.id)) continue; if (run.status === "waiting") { const checkpointEffects = yield* outbox .listByCommandId(CommandId.make(`command:effect:checkpoint.capture:${run.id}`)) @@ -430,7 +447,10 @@ export const make = Effect.gen(function* () { const recoveredNonterminalRunIds = new Set(runs.map((run) => run.id)); const cancelledStaleNodeIds = new Set(); for (const item of projection.turnItems ?? []) { - if (item.runId !== null && recoveredNonterminalRunIds.has(item.runId)) { + if ( + item.runId !== null && + (recoveredNonterminalRunIds.has(item.runId) || repairRunIds.has(item.runId)) + ) { continue; } if (!isBackgroundCapableTurnItemType(item.type)) { @@ -636,16 +656,17 @@ export const make = Effect.gen(function* () { continueAfterRestart && trigger === "startup" ? restartContinuationRun(projection, new Set(cancelledBackgroundWork.keys())) : undefined; - const effects: Array = continuationRun - ? [ - { - id: `effect:restart-continuation:${continuationRun.id}`, - commandId, - threadId: projection.thread.id, - request: { type: "provider-runtime.continue", sourceRunId: continuationRun.id }, - }, - ] - : []; + const effects: Array = + continuationRun && !repairRunIds.has(continuationRun.id) + ? [ + { + id: `effect:restart-continuation:${continuationRun.id}`, + commandId, + threadId: projection.thread.id, + request: { type: "provider-runtime.continue", sourceRunId: continuationRun.id }, + }, + ] + : []; const stoppedSessions = projection.providerSessions.filter( (candidate) => candidate.status !== "stopped" && candidate.status !== "error", ).length; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 11c44640f297..a1c39da17939 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -45,7 +45,7 @@ import { import * as IdAllocator from "./IdAllocator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; -import { makeProviderFailure } from "./ProviderFailure.ts"; +import { makeProviderFailure, makeProviderFailureTurnItem } from "./ProviderFailure.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; import * as RuntimePolicy from "./RuntimePolicy.ts"; import { @@ -75,6 +75,12 @@ export interface ProviderTurnStartServiceV2Shape { readonly runId: RunId; readonly willRetry?: boolean; }) => Effect.Effect; + readonly failFromDeadLetter: (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly error: string; + readonly expectedAttemptId?: OrchestrationV2RunAttempt["id"] | null; + }) => Effect.Effect; } export class ProviderTurnStartServiceV2 extends Context.Service< @@ -547,33 +553,48 @@ export const layer: Layer.Layer< // instead of leaving it `starting` after the effect gives up. A run that // already left `starting` is not overwritten, and a failed write returns // its error to the effect worker. + const startFailure = (error: Error) => { + const nestedCause = "cause" in error ? error.cause : undefined; + return makeProviderFailure({ + cause: error, + message: + nestedCause instanceof Error + ? nestedCause.message + : typeof nestedCause === "string" + ? nestedCause + : error.message, + class: "provider_error", + }); + }; const settleStartFailure = (failed: { readonly signal: string; readonly title: string; readonly error: Error; }) => Effect.gen(function* () { - const nestedCause = "cause" in failed.error ? failed.error.cause : undefined; + // A restart that cannot reopen its session or reload its native + // thread also settles the work it inherited from the earlier attempt. + if (attempt.attemptOrdinal > 1) { + return yield* settleFailedStart({ + threadId: projection.thread.id, + runId, + error: failed.error.message, + openFailure: { + signal: failed.signal, + title: failed.title, + failure: startFailure(failed.error), + now: yield* DateTime.now, + attemptId: attempt.id, + }, + }); + } yield* settleRunBeforeStart({ signal: failed.signal, status: "failed", now: yield* DateTime.now, providerInstanceId: run.providerInstanceId, itemProviderThreadId: providerThread.id, - item: { - type: "error", - title: failed.title, - failure: makeProviderFailure({ - cause: failed.error, - message: - nestedCause instanceof Error - ? nestedCause.message - : typeof nestedCause === "string" - ? nestedCause - : failed.error.message, - class: "provider_error", - }), - }, + item: { type: "error", title: failed.title, failure: startFailure(failed.error) }, }); }); if (sessionResult._tag === "Failure") { @@ -1281,6 +1302,455 @@ export const layer: Layer.Layer< }); }); + // Failed restarts and exhausted start effects must settle inherited work + // along with the root. The attempt CAS protects a concurrently replaced run. + const settleFailedStart = Effect.fn("orchestrationV2.providerTurnStart.settleFailedStart")( + function* (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly error: string; + readonly expectedAttemptId?: OrchestrationV2RunAttempt["id"] | null; + readonly openFailure?: { + readonly signal: string; + readonly title: string; + readonly failure: ReturnType; + readonly now: DateTime.Utc; + readonly attemptId: OrchestrationV2RunAttempt["id"]; + }; + }) { + // The failure cascade needs terminal lifetime links and runless child rows. + // Successful starts and fresh open failures retain their bounded reads. + const projection = yield* projectionStore.getThreadProjection(input.threadId); + const run = projection.runs.find((candidate) => candidate.id === input.runId); + if (run === undefined || run.status !== "starting" || run.activeAttemptId === null) { + return; + } + const activeAttemptId = run.activeAttemptId; + if (input.expectedAttemptId !== undefined && input.expectedAttemptId !== activeAttemptId) + return; + if (input.openFailure !== undefined && input.openFailure.attemptId !== activeAttemptId) { + return; + } + const attempt = projection.attempts.find((candidate) => candidate.id === activeAttemptId); + const rootNode = projection.nodes.find((candidate) => candidate.id === run.rootNodeId); + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === run.providerThreadId, + ); + const now = input.openFailure?.now ?? (yield* DateTime.now); + const failure = + input.openFailure?.failure ?? + makeProviderFailure({ + message: "Starting the provider turn failed permanently.", + retryable: false, + }); + if (input.openFailure === undefined) { + yield* Effect.logError("Provider turn start exhausted its retry budget", { + threadId: input.threadId, + runId: input.runId, + error: input.error, + }); + } + const runtimeRequestCancellationReason = + "The provider turn failed before this runtime request was resolved."; + const events: Array = []; + if (attempt !== undefined) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "run-attempt.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: attempt.rootNodeId, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...attempt, status: "failed", completedAt: now }, + }); + } + if (rootNode !== undefined) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "node.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...rootNode, status: "failed", completedAt: now }, + }); + } + // A restart can inherit open run-owned work from the interrupted + // attempt (subagents, child nodes, provider turns, streaming + // messages, in-flight turn items). Live finalization cascades those + // through `cascadeTerminalizeRunOwnedSubagents`; this path sweeps the + // parent projection here and linked child threads below, so nothing + // stays open under the failed run. + for (const subagent of projection.subagents.filter( + (candidate) => + candidate.runId === run.id && + (candidate.status === "pending" || + candidate.status === "running" || + candidate.status === "waiting"), + )) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "subagent.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: subagent.id, + driver: subagent.driver, + providerInstanceId: subagent.providerInstanceId, + occurredAt: now, + payload: { ...subagent, status: "failed", completedAt: now, updatedAt: now }, + }); + } + for (const node of projection.nodes.filter( + (candidate) => + candidate.runId === run.id && + candidate.id !== run.rootNodeId && + (candidate.status === "pending" || + candidate.status === "running" || + candidate.status === "waiting"), + )) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "node.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: node.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...node, status: "failed", completedAt: now }, + }); + } + for (const providerTurn of projection.providerTurns.filter( + (candidate) => + candidate.runAttemptId !== null && + projection.attempts.some( + (attemptRow) => + attemptRow.id === candidate.runAttemptId && attemptRow.runId === run.id, + ) && + (candidate.status === "pending" || candidate.status === "running"), + )) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "provider-turn.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: providerTurn.nodeId, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...providerTurn, status: "cancelled", completedAt: now }, + }); + } + for (const request of projection.runtimeRequests.filter( + (candidate) => + candidate.status === "pending" && + projection.nodes.some((node) => node.id === candidate.nodeId && node.runId === run.id), + )) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "runtime-request.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: request.nodeId, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + ...request, + status: "cancelled", + responseCapability: { + type: "not_resumable", + reason: runtimeRequestCancellationReason, + }, + resolvedAt: now, + }, + }); + } + for (const message of projection.messages.filter( + (candidate) => candidate.runId === run.id && candidate.streaming, + )) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "message.updated", + threadId: projection.thread.id, + runId: run.id, + ...(message.nodeId === null ? {} : { nodeId: message.nodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...message, streaming: false, updatedAt: now }, + }); + } + for (const item of projection.turnItems.filter( + (candidate) => + candidate.runId === run.id && + (candidate.status === "pending" || + candidate.status === "running" || + candidate.status === "waiting"), + )) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "turn-item.updated", + threadId: projection.thread.id, + runId: run.id, + ...(item.nodeId === null ? {} : { nodeId: item.nodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + ...item, + ...("streaming" in item ? { streaming: false } : {}), + status: "failed", + completedAt: now, + updatedAt: now, + }, + }); + } + // Linked child threads carry the interrupted attempt's routed rows, + // usually with `runId: null`, and provider-native children have no + // runs of their own, so neither the per-run sweep above nor startup + // reconcile (which iterates per-run rows) ever settles them. Follow + // lifetime linkage the way `cascadeTerminalizeRunOwnedSubagents` + // does: child thread ids come from all of this run's subagent rows + // and subagent turn items, terminal links included (a link can + // terminalize before the child settles), recursing through nested + // subagents. A child row that names a nonterminal run in its own + // thread belongs to an independently live child (an app-owned + // delegation) and is left alone; the same guard decides which nested + // links to follow. + const isNonterminalRunStatus = (status: string) => + status === "queued" || + status === "preparing" || + status === "starting" || + status === "running" || + status === "waiting"; + const isOpenRowStatus = (status: string) => + status === "pending" || status === "running" || status === "waiting"; + const visitedThreadIds = new Set([projection.thread.id]); + const childThreadQueue: Array = []; + const enqueueChildThread = (childThreadId: ThreadId | null) => { + if (childThreadId === null || visitedThreadIds.has(childThreadId)) { + return; + } + visitedThreadIds.add(childThreadId); + childThreadQueue.push(childThreadId); + }; + for (const subagent of projection.subagents) { + if (subagent.runId === run.id) enqueueChildThread(subagent.childThreadId); + } + for (const item of projection.turnItems) { + if (item.type === "subagent" && item.runId === run.id) { + enqueueChildThread(item.childThreadId); + } + } + while (childThreadQueue.length > 0) { + const childThreadId = childThreadQueue.shift(); + if (childThreadId === undefined) break; + const childResult = yield* Effect.result( + projectionStore.getThreadProjection(childThreadId), + ); + if (childResult._tag === "Failure") { + if (childResult.failure._tag !== "ProjectionStoreThreadNotFoundError") { + return yield* Effect.fail(childResult.failure); + } + continue; + } + const child = childResult.success; + // Live app-owned threads can also carry session-scoped, runless work. + if (child.runs.some((candidate) => isNonterminalRunStatus(candidate.status))) continue; + const sweepable = (rowRunId: RunId | null) => { + if (rowRunId === null || rowRunId === run.id) return true; + const owningRun = child.runs.find((candidate) => candidate.id === rowRunId); + return owningRun === undefined || !isNonterminalRunStatus(owningRun.status); + }; + for (const subagent of child.subagents) { + if (!sweepable(subagent.runId)) continue; + enqueueChildThread(subagent.childThreadId); + if (!isOpenRowStatus(subagent.status)) continue; + events.push({ + id: yield* idAllocator.allocate.event({ threadId: childThreadId }), + type: "subagent.updated", + threadId: childThreadId, + runId: subagent.runId ?? run.id, + nodeId: subagent.id, + driver: subagent.driver, + providerInstanceId: subagent.providerInstanceId, + occurredAt: now, + payload: { ...subagent, status: "failed", completedAt: now, updatedAt: now }, + }); + } + for (const item of child.turnItems) { + if (!sweepable(item.runId)) continue; + if (item.type === "subagent") enqueueChildThread(item.childThreadId); + if (!isOpenRowStatus(item.status)) continue; + events.push({ + id: yield* idAllocator.allocate.event({ threadId: childThreadId }), + type: "turn-item.updated", + threadId: childThreadId, + runId: item.runId ?? run.id, + ...(item.nodeId === null ? {} : { nodeId: item.nodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + ...item, + ...("streaming" in item ? { streaming: false } : {}), + status: "failed", + completedAt: now, + updatedAt: now, + }, + }); + } + for (const node of child.nodes) { + if (!sweepable(node.runId) || !isOpenRowStatus(node.status)) continue; + events.push({ + id: yield* idAllocator.allocate.event({ threadId: childThreadId }), + type: "node.updated", + threadId: childThreadId, + runId: node.runId ?? run.id, + nodeId: node.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...node, status: "failed", completedAt: now }, + }); + } + for (const providerTurn of child.providerTurns) { + if (providerTurn.status !== "pending" && providerTurn.status !== "running") { + continue; + } + const node = child.nodes.find((candidate) => candidate.id === providerTurn.nodeId); + const attempt = + providerTurn.runAttemptId === null + ? undefined + : child.attempts.find((candidate) => candidate.id === providerTurn.runAttemptId); + const ownerRunIds = [ + ...(node === undefined ? [] : [node.runId]), + ...(attempt === undefined ? [] : [attempt.runId]), + ]; + if ( + ownerRunIds.length === 0 || + ownerRunIds.some((ownerRunId) => !sweepable(ownerRunId)) + ) { + continue; + } + events.push({ + id: yield* idAllocator.allocate.event({ threadId: childThreadId }), + type: "provider-turn.updated", + threadId: childThreadId, + runId: attempt?.runId ?? node?.runId ?? run.id, + nodeId: providerTurn.nodeId, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...providerTurn, status: "cancelled", completedAt: now }, + }); + } + for (const request of child.runtimeRequests) { + const node = child.nodes.find((candidate) => candidate.id === request.nodeId); + if (request.status !== "pending" || node === undefined || !sweepable(node.runId)) { + continue; + } + events.push({ + id: yield* idAllocator.allocate.event({ threadId: childThreadId }), + type: "runtime-request.updated", + threadId: childThreadId, + runId: node.runId ?? run.id, + nodeId: request.nodeId, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + ...request, + status: "cancelled", + responseCapability: { + type: "not_resumable", + reason: runtimeRequestCancellationReason, + }, + resolvedAt: now, + }, + }); + } + for (const message of child.messages) { + if (!message.streaming || !sweepable(message.runId)) continue; + events.push({ + id: yield* idAllocator.allocate.event({ threadId: childThreadId }), + type: "message.updated", + threadId: childThreadId, + ...(message.runId === null ? {} : { runId: message.runId }), + ...(message.nodeId === null ? {} : { nodeId: message.nodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...message, streaming: false, updatedAt: now }, + }); + } + } + if (providerThread !== undefined) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "turn-item.updated", + threadId: projection.thread.id, + runId: run.id, + ...(rootNode === undefined ? {} : { nodeId: rootNode.id }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + ...makeProviderFailureTurnItem({ + idAllocator, + driver: providerThread.driver, + threadId: projection.thread.id, + runId: run.id, + nodeId: rootNode?.id ?? null, + providerThreadId: providerThread.id, + providerTurnId: + attempt?.providerTurnId ?? + idAllocator.derive.providerTurn({ + driver: providerThread.driver, + nativeTurnId: `failed:${activeAttemptId}`, + }), + itemOrdinal: Math.max(0, ...projection.turnItems.map((item) => item.ordinal)) + 1, + failure, + occurredAt: now, + }), + ...(input.openFailure === undefined + ? {} + : { + id: idAllocator.derive.runSignalTurnItem({ + runId: run.id, + signal: input.openFailure.signal, + }), + title: input.openFailure.title, + providerTurnId: null, + }), + }, + }); + } + if (providerThread !== undefined && providerThread.status === "active") { + // This rides the same guarded commit, so it only lands while the run + // is still `starting` on our attempt and the binding is still ours. + events.push({ + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "provider-thread.updated", + threadId: projection.thread.id, + driver: providerThread.driver, + providerInstanceId: providerThread.providerInstanceId, + occurredAt: now, + payload: { ...providerThread, status: "idle", updatedAt: now }, + }); + } + events.push({ + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "run.updated", + threadId: projection.thread.id, + runId: run.id, + ...(rootNode === undefined ? {} : { nodeId: rootNode.id }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, status: "failed", queuePosition: null, completedAt: now }, + }); + yield* eventSink.writeIfRunCurrent({ + threadId: projection.thread.id, + runId: run.id, + activeAttemptId, + expectedStatus: "starting", + events, + }); + }, + ); + return ProviderTurnStartServiceV2.of({ start: (input) => start(input).pipe( @@ -1290,6 +1760,14 @@ export const layer: Layer.Layer< : new ProviderTurnStartError({ runId: input.runId, cause }), ), ), + failFromDeadLetter: (input) => + settleFailedStart(input).pipe( + Effect.mapError((cause) => + isProviderTurnStartError(cause) + ? cause + : new ProviderTurnStartError({ runId: input.runId, cause }), + ), + ), }); }), ); diff --git a/apps/server/src/orchestration-v2/RepairRetry.test.ts b/apps/server/src/orchestration-v2/RepairRetry.test.ts new file mode 100644 index 000000000000..72d5864350a6 --- /dev/null +++ b/apps/server/src/orchestration-v2/RepairRetry.test.ts @@ -0,0 +1,166 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + RunAttemptId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; + +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { EffectOutboxV2, layer as effectOutboxLayer } from "./EffectOutbox.ts"; +import { + layerWithOptions, + OrchestrationEffectExecutionError, + OrchestrationEffectExecutorV2, + OrchestrationEffectWorkerV2, +} from "./EffectWorker.ts"; + +it.effect( + "retries repair past exhaustion with capped exponential backoff and no provider start", + () => + Effect.gen(function* () { + const executions = yield* Ref.make(0); + const compensations = yield* Ref.make(0); + const executor = Layer.succeed( + OrchestrationEffectExecutorV2, + OrchestrationEffectExecutorV2.of({ + execute: (effect) => + Ref.update(executions, (n) => n + 1).pipe( + Effect.andThen( + Effect.fail( + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause: "provider start failed", + }), + ), + ), + ), + compensateDeadLetter: (effect) => + Ref.update(compensations, (n) => n + 1).pipe( + Effect.andThen( + Effect.fail( + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause: "deterministic compensation failure", + }), + ), + ), + ), + }), + ); + const outboxLayer = effectOutboxLayer.pipe(Layer.provideMerge(SqlitePersistenceMemory)); + const layer = layerWithOptions({ workerId: "review-probe", maxAttempts: 1 }).pipe( + Layer.provideMerge(Layer.merge(outboxLayer, executor)), + ); + yield* Effect.gen(function* () { + const outbox = yield* EffectOutboxV2; + const worker = yield* OrchestrationEffectWorkerV2; + const effectId = "effect:review-repair-loop"; + yield* outbox.enqueue([ + { + id: effectId, + commandId: CommandId.make(effectId), + threadId: ThreadId.make("thread:review-repair-loop"), + request: { type: "provider-turn.start", runId: RunId.make("run:review-repair-loop") }, + }, + ]); + for (let i = 0; i < 12; i += 1) { + const now = yield* DateTime.now; + const result = yield* worker.runOnce.pipe(Effect.result); + const stored = Option.getOrUndefined(yield* outbox.get(effectId)); + const nextClaimableAt = yield* outbox.nextClaimableAt; + const delayMs = Math.min(30_000, 100 * 2 ** i); + const availableAt = DateTime.formatIso(DateTime.add(now, { milliseconds: delayMs })); + assert.equal(result._tag, "Failure"); + assert.equal(stored?.request.type, "provider-turn.repair"); + assert.equal(stored?.status, "pending"); + assert.equal(stored?.attemptCount, i + 1); + assert.equal(stored?.availableAt, availableAt); + assert.equal(DateTime.formatIso(Option.getOrThrow(nextClaimableAt)), availableAt); + assert.isFalse(yield* worker.runOnce); + yield* TestClock.adjust(delayMs); + } + assert.equal(yield* Ref.get(executions), 1); + assert.equal(yield* Ref.get(compensations), 12); + const final = Option.getOrUndefined(yield* outbox.get(effectId)); + assert.equal(final?.request.type, "provider-turn.repair"); + assert.equal(final?.status, "pending"); + assert.equal(final?.attemptCount, 12); + }).pipe(Effect.provide(layer)); + }), +); + +for (const type of ["provider-turn.start", "provider-turn.restart"] as const) { + it.effect(`repairs a reclaimed ${type} past its retry budget without execution`, () => + Effect.gen(function* () { + const executions = yield* Ref.make(0); + const repairs = yield* Ref.make(0); + const dependencies = Layer.merge( + effectOutboxLayer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + Layer.succeed(OrchestrationEffectExecutorV2, { + execute: () => Ref.update(executions, (n) => n + 1), + compensateDeadLetter: () => Ref.update(repairs, (n) => n + 1), + }), + ); + yield* Effect.gen(function* () { + const outbox = yield* EffectOutboxV2; + const worker = yield* OrchestrationEffectWorkerV2; + const id = `exhausted-claim:${type}`; + yield* outbox.enqueue([ + { + id, + commandId: CommandId.make(id), + threadId: ThreadId.make(id), + request: + type === "provider-turn.start" + ? { type, runId: RunId.make(id) } + : { + type, + runId: RunId.make(id), + providerSessionId: ProviderSessionId.make(id), + providerThreadId: ProviderThreadId.make(id), + providerTurnId: ProviderTurnId.make(id), + interruptedAttemptId: RunAttemptId.make(id), + sessionTransition: { type: "detach" }, + }, + }, + ]); + assert.isTrue( + Option.isSome(yield* outbox.claimNext({ workerId: "previous", leaseDurationMs: 1000 })), + ); + assert.isTrue( + yield* outbox.retry({ + effectId: id, + workerId: "previous", + error: "settlement failed", + delayMs: 0, + }), + ); + assert.isTrue(yield* worker.runOnce); + assert.equal(yield* Ref.get(executions), 0); + assert.equal(yield* Ref.get(repairs), 1); + const stored = Option.getOrThrow(yield* outbox.get(id)); + assert.equal(stored.attemptCount, 2); + assert.equal(stored.status, "failed"); + assert.equal(stored.request.type, "provider-turn.repair"); + }).pipe( + Effect.provide( + layerWithOptions({ workerId: "next", maxAttempts: 1 }).pipe( + Layer.provideMerge(dependencies), + ), + ), + ); + }), + ); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/exhausted_start_settlement/RetryReplay.integration.test.ts b/apps/server/src/orchestration-v2/testkit/fixtures/exhausted_start_settlement/RetryReplay.integration.test.ts new file mode 100644 index 000000000000..ab7aac2be4a1 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/exhausted_start_settlement/RetryReplay.integration.test.ts @@ -0,0 +1,100 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { TestClock } from "effect/testing"; + +import { CodexOrchestratorReplayHarness } from "../../../Adapters/CodexAdapterV2.testkit.ts"; +import { OrchestrationEffectWorkerV2, runDaemon } from "../../../EffectWorker.ts"; +import { layer as idAllocatorLayer } from "../../../IdAllocator.ts"; +import { OrchestratorV2 } from "../../../Orchestrator.ts"; +import { ProviderAdapterOpenSessionError } from "../../../ProviderAdapter.ts"; +import { ProviderAdapterRegistryV2 } from "../../../ProviderAdapterRegistry.ts"; +import { provideDeterministicTestRuntime } from "../../DeterministicRuntime.ts"; +import { runOrchestratorV2Scenario } from "../../OrchestratorScenario.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "../../ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "../../ReplayFixtureWorkspace.ts"; +import { + materializeReplayTranscriptWorkspace, + readProviderReplayTranscript, +} from "../../ReplayTranscriptNdjson.ts"; +import { simpleInput } from "../simple/input.ts"; +import { CODEX_MODEL_SELECTION, materializeFixtureInput } from "../shared.ts"; + +it.effect("retries a failed open before consuming the successful provider transcript", () => + Effect.gen(function* () { + const workspace = yield* checkpointWorkspace("retry-open"); + const raw = yield* readProviderReplayTranscript( + new URL("../simple/codex_transcript.ndjson", import.meta.url), + ).pipe(Effect.provide(NodeServices.layer)); + const transcript = yield* CodexOrchestratorReplayHarness.decodeTranscript( + materializeReplayTranscriptWorkspace(raw, workspace), + ); + const input = yield* materializeFixtureInput({ + scenario: "retry-open", + fixtureInput: simpleInput(), + driver: CodexOrchestratorReplayHarness.driver, + modelSelection: CODEX_MODEL_SELECTION, + }).pipe(Effect.provide(idAllocatorLayer)); + let opens = 0; + const registry = Layer.effect( + ProviderAdapterRegistryV2, + Effect.gen(function* () { + const delegate = yield* ProviderAdapterRegistryV2; + return ProviderAdapterRegistryV2.of({ + ...delegate, + get: (id) => + delegate.get(id).pipe( + Effect.map((adapter) => ({ + ...adapter, + openSession: (request) => + Effect.gen(function* () { + opens += 1; + if (opens === 1) + return yield* new ProviderAdapterOpenSessionError({ + driver: adapter.driver, + providerSessionId: request.providerSessionId, + cause: "transient open failure", + }); + return yield* adapter.openSession(request); + }), + })), + ), + }); + }), + ).pipe( + Layer.provide(CodexOrchestratorReplayHarness.makeProviderAdapterRegistryLayer(transcript)), + ); + const layer = makeOrchestratorV2ReplayLayerWithRegistry( + { name: "retry-open", runtimePolicyOverride: { cwd: workspace } }, + registry, + { runEffectWorker: false }, + ); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + for (const command of input.commands) yield* orchestrator.dispatch(command); + yield* worker.drain(); + assert.equal(opens, 1); + const threadId = input.projectionThreadIds[0]!; + const before = yield* orchestrator.getThreadProjection(threadId); + assert.equal(before.runs[0]?.status, "starting"); + assert.isFalse(before.turnItems.some((item) => item.type === "error")); + yield* TestClock.adjust("100 millis"); + yield* runDaemon.pipe(Effect.forkScoped); + const result = yield* runOrchestratorV2Scenario({ + name: "retry-open", + commands: [], + steps: [{ type: "await_thread_idle", threadId }], + projectionThreadIds: input.projectionThreadIds, + }); + const after = result.projections.get(threadId)!; + assert.equal(opens, 2); + assert.equal(after.runs[0]?.status, "completed"); + assert.isTrue( + after.messages.some((message) => message.role === "assistant" && message.text.length > 0), + ); + assert.isFalse(after.turnItems.some((item) => item.type === "error")); + }).pipe(Effect.provide(layer)); + }).pipe(provideDeterministicTestRuntime, Effect.scoped), +); From 38471b9e84730a9a62affbe77f9d458acbdd4956 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Fri, 25 Sep 2026 04:49:20 -0400 Subject: [PATCH 18/34] fix(orchestrator): Recover cancelled deliveries only on explicit rearm --- .../DelegatedCompletionDelivery.test.ts | 1061 ++++++++++++++++- .../src/orchestration-v2/Orchestrator.ts | 148 ++- 2 files changed, 1137 insertions(+), 72 deletions(-) diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index a64dc21cec91..c5b270f4bcde 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -15,9 +15,13 @@ import { ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ServerConfig from "../config.ts"; @@ -34,6 +38,7 @@ import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; import { OrchestrationV2EventSinkLayerLive, @@ -103,50 +108,227 @@ const TestProviderInstanceRegistry = Layer.succeed( }, ); -const TestLayer = Layer.mergeAll(OrchestrationV2LayerLive, OrchestrationV2EventSinkLayerLive).pipe( - Layer.provideMerge(ProjectServiceLayerLive), - Layer.provide( - Layer.mock(WorkspacePaths.WorkspacePaths)({ - normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot), - }), - ), - Layer.provide(worktreeRepairDependenciesTestLayer), - Layer.provide( - Layer.succeed(ProjectEnrichmentService.ProjectEnrichmentService, { - peek: () => - Effect.succeed({ - repositoryIdentity: null, - faviconPath: null, - repositoryIdentityResolved: false, - }), - request: () => Effect.void, - getAvailable: () => - Effect.succeed({ - repositoryIdentity: null, - faviconPath: null, - repositoryIdentityResolved: false, - }), - invalidate: () => Effect.void, - subscribeChanges: Effect.never, - }), - ), - Layer.provide(McpSessionRegistryTestkit.layer), - Layer.provide(SqlitePersistenceMemory), - Layer.provide(CheckpointStoreTestLayer), - Layer.provide(ServerConfigLayer), - Layer.provide(ServerSettings.layerTest()), - Layer.provide(TestProviderInstanceRegistry), - Layer.provide(PlatformTestLayer), +const makeTestLayer = (database: Layer.Layer) => + Layer.mergeAll( + OrchestrationV2LayerLive, + OrchestrationV2EventSinkLayerLive, + ProjectionStore.layer, + ).pipe( + Layer.provideMerge(ProjectServiceLayerLive), + Layer.provide( + Layer.mock(WorkspacePaths.WorkspacePaths)({ + normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot), + }), + ), + Layer.provide(worktreeRepairDependenciesTestLayer), + Layer.provide( + Layer.succeed(ProjectEnrichmentService.ProjectEnrichmentService, { + peek: () => + Effect.succeed({ + repositoryIdentity: null, + faviconPath: null, + repositoryIdentityResolved: false, + }), + request: () => Effect.void, + getAvailable: () => + Effect.succeed({ + repositoryIdentity: null, + faviconPath: null, + repositoryIdentityResolved: false, + }), + invalidate: () => Effect.void, + subscribeChanges: Effect.never, + }), + ), + Layer.provide(McpSessionRegistryTestkit.layer), + Layer.provideMerge(database), + Layer.provide(CheckpointStoreTestLayer), + Layer.provide(ServerConfigLayer), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(TestProviderInstanceRegistry), + Layer.provide(PlatformTestLayer), + ); + +const TestLayer = makeTestLayer(SqlitePersistenceMemory); + +it.effect("live upgrade reserves from the planned state", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("planned-state"); + const runId = RunId.make("planned-state:parent"); + const xId = NodeId.make("planned-state:x"); + const yId = NodeId.make("planned-state:y"); + yield* seedParentWithTerminalTask({ + threadId, + runId, + taskId: NodeId.make("planned-state:control"), + now, + projectId: ProjectId.make("planned-state:project"), + rootNodeId: NodeId.make("planned-state:root"), + completionWake: "always", + deliveryState: "delivered", + parentStatus: "running", + }); + const seed = yield* orchestrator.getThreadProjection(threadId); + const { completionDelivery: _omit, ...base } = seed.subagents[0]!; + yield* sink.write({ + events: [ + { + id: EventId.make("planned-state:x"), + type: "subagent.updated", + threadId, + runId, + occurredAt: now, + payload: { ...base, id: xId, completionWake: "settled_only" }, + }, + { + id: EventId.make("planned-state:y"), + type: "subagent.updated", + threadId, + runId, + occurredAt: now, + payload: { + ...base, + id: yId, + completionWake: "always", + completionDelivery: { state: "pending", observedByRunId: null }, + }, + }, + ], + }); + const upgrade = yield* orchestrator.dispatch({ + type: "delegated_task.wake-policy", + commandId: CommandId.make("planned-state:upgrade"), + parentThreadId: threadId, + taskId: xId, + completionWake: "always", + }); + const reservations = upgrade.storedEvents.flatMap(({ event }) => + event.type === "run.updated" && + event.payload.id === runId && + event.payload.delegatedCompletion?.delivery != null + ? [event.payload.delegatedCompletion.delivery] + : [], + ); + const after = yield* orchestrator.getThreadProjection(threadId); + const cohort = after.runs.find((run) => run.id === runId)?.delegatedCompletion; + assert.equal(reservations.length, 1, "one reservation per generation"); + assert.deepEqual(cohort?.delivery?.taskIds, [xId]); + assert.equal(cohort?.delivery?.generation, 2); + assert.equal(cohort?.nextGeneration, 3); + assert.equal( + after.subagents.find((task) => task.id === xId)?.completionDelivery?.state, + "claimed", + ); + assert.equal( + after.subagents.find((task) => task.id === yId)?.completionDelivery?.state, + "pending", + ); + }).pipe(Effect.provide(Layer.fresh(TestLayer))), ); +it.effect("rearmed target keeps its own new policy", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("target-policy"); + const runId = RunId.make("target-policy:parent"); + const taskId = NodeId.make("target-policy:task"); + yield* seedParentWithTerminalTask({ + threadId, + runId, + taskId, + now, + projectId: ProjectId.make("target-policy:project"), + rootNodeId: NodeId.make("target-policy:root"), + completionWake: "always", + deliveryState: "pending", + parentStatus: "completed", + }); + yield* orchestrator.dispatch({ + type: "delegated_task.wake-policy", + commandId: CommandId.make("target-policy:rearm"), + parentThreadId: threadId, + taskId, + completionWake: "settled_only", + }); + const after = yield* orchestrator.getThreadProjection(threadId); + const task = after.subagents.find((candidate) => candidate.id === taskId); + assert.equal(task?.completionDelivery?.state, "claimed"); + assert.equal( + task?.completionWake, + "settled_only", + "the command's own policy change was clobbered", + ); + }).pipe(Effect.provide(Layer.fresh(TestLayer))), +); + +for (const parentStatus of ["running", "completed"] as const) { + it.effect( + `reservation planning failures retain the wake-policy command identity (${parentStatus})`, + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const store = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make(`planning-error:${parentStatus}`); + const taskId = NodeId.make("planning-error:task"); + yield* seedParentWithTerminalTask({ + threadId, + runId: RunId.make("planning-error:parent"), + taskId, + now, + projectId: ProjectId.make("planning-error:project"), + rootNodeId: NodeId.make("planning-error:root"), + completionWake: "settled_only", + deliveryState: "pending", + parentStatus, + }); + const command = { + type: "delegated_task.wake-policy" as const, + commandId: CommandId.make("planning-error:upgrade"), + parentThreadId: threadId, + taskId, + completionWake: "always" as const, + }; + const original = store.getMessageCount; + yield* Effect.acquireUseRelease( + Effect.sync(() => { + Object.assign(store, { + getMessageCount: () => + Effect.fail(new ProjectionStore.ProjectionStoreThreadNotFoundError({ threadId })), + }); + }), + () => + Effect.gen(function* () { + const error = yield* orchestrator.dispatch(command).pipe(Effect.flip); + assert.equal(error._tag, "OrchestratorDispatchError"); + if (error._tag !== "OrchestratorDispatchError") return; + assert.equal(error.commandId, command.commandId); + assert.equal(error.commandType, command.type); + }), + () => + Effect.sync(() => { + Object.assign(store, { getMessageCount: original }); + }), + ); + const retry = yield* orchestrator.dispatch(command).pipe(Effect.flip); + assert.equal(retry._tag, "OrchestratorCommandPreviouslyRejectedError"); + }).pipe(Effect.provide(Layer.fresh(TestLayer))), + ); +} + const seedParentWithTerminalTask = (input: { readonly threadId: ThreadId; readonly projectId: ProjectId; readonly runId: RunId; readonly rootNodeId: NodeId; readonly taskId: NodeId; - readonly deliveryState: "delivered" | "claimed" | "acknowledged" | "disposed"; + readonly deliveryState: "pending" | "delivered" | "claimed" | "acknowledged" | "disposed"; readonly completionWake?: "always" | "settled_only"; + readonly parentStatus?: "running" | "completed"; readonly deliveryTaskIds?: ReadonlyArray; readonly now: DateTime.Utc; }) => @@ -230,10 +412,10 @@ const seedParentWithTerminalTask = (input: { userMessageId: MessageId.make(`message:seed-user:${input.threadId}`), rootNodeId: input.rootNodeId, activeAttemptId: null, - status: "running", + status: input.parentStatus ?? "running", requestedAt: input.now, startedAt: input.now, - completedAt: null, + completedAt: input.parentStatus === "completed" ? input.now : null, checkpointId: null, contextHandoffId: null, delegatedCompletion: { @@ -360,6 +542,7 @@ it.layer(TestLayer)("delegated completion delivery repairs", (it) => { messageId, }); const accepted = yield* orchestrator.getThreadProjection(threadId); + assert.notEqual(accepted.runs.find((run) => run.id === runId)?.userMessageId, messageId); assert.equal( accepted.subagents.find((row) => row.id === taskId)?.completionDelivery?.state, "delivered", @@ -651,4 +834,808 @@ it.layer(TestLayer)("delegated completion delivery repairs", (it) => { ); }), ); + + it.effect("reserves concurrent explicit rearms atomically after a failed reservation write", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + const threadId = ThreadId.make("atomic-rearm"); + const runId = RunId.make("atomic-rearm:parent"); + const deliveredId = NodeId.make("atomic-rearm:delivered"); + const siblingIds = [NodeId.make("atomic-rearm:third"), NodeId.make("atomic-rearm:second")]; + yield* seedParentWithTerminalTask({ + threadId, + runId, + taskId: deliveredId, + now, + projectId: ProjectId.make("atomic-rearm:project"), + rootNodeId: NodeId.make("atomic-rearm:root"), + completionWake: "always", + deliveryState: "delivered", + parentStatus: "completed", + }); + const seed = yield* orchestrator.getThreadProjection(threadId); + yield* sink.write({ + events: [ + ...siblingIds, + ...["one", "two", "three"].map((name) => NodeId.make(`atomic-rearm:control:${name}`)), + ].map((id) => ({ + id: EventId.make(`seed:${id}`), + type: "subagent.updated" as const, + threadId, + runId, + occurredAt: now, + payload: { + ...seed.subagents[0]!, + id, + completionDelivery: { + state: siblingIds.includes(id) ? ("pending" as const) : ("delivered" as const), + observedByRunId: null, + }, + }, + })), + }); + const rearm = (id: string, taskId = deliveredId) => + orchestrator.dispatch({ + type: "delegated_task.wake-policy", + commandId: CommandId.make(id), + parentThreadId: threadId, + taskId, + completionWake: "settled_only", + }); + yield* sql.unsafe( + "CREATE TEMP TRIGGER fail_reservation BEFORE UPDATE ON orchestration_v2_projection_runs WHEN NEW.run_id = 'atomic-rearm:parent' BEGIN SELECT RAISE(ABORT, 'simulated reservation write failure'); END", + ); + const beforeSequence = yield* sink.latestSequence(); + yield* rearm("atomic-rearm:failed").pipe(Effect.flip); + assert.equal(yield* sink.latestSequence(), beforeSequence); + const failed = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + failed.subagents.find((task) => task.id === deliveredId)?.completionWake, + "always", + ); + assert.equal(failed.runs[0]?.delegatedCompletion?.delivery, null); + assert.equal(failed.runs[0]?.delegatedCompletion?.nextGeneration, 2); + for (const id of siblingIds) + assert.equal( + failed.subagents.find((task) => task.id === id)?.completionDelivery?.state, + "pending", + ); + yield* sql.unsafe("DROP TRIGGER fail_reservation"); + const afterSequence = yield* sink.latestSequence(); + // Retry the same command after rollback, not an unrelated policy toggle. + yield* rearm("atomic-rearm:failed"); + yield* Effect.all( + ["one", "two", "three"].map((name) => + rearm(`atomic-rearm:${name}`, NodeId.make(`atomic-rearm:control:${name}`)), + ), + { concurrency: "unbounded" }, + ); + const reserved = yield* orchestrator.getThreadProjection(threadId); + const cohort = reserved.runs[0]!.delegatedCompletion!; + assert.equal(cohort.nextGeneration, 3); + assert.deepEqual(cohort.delivery?.taskIds, siblingIds.toSorted()); + assert.equal( + reserved.subagents.find((task) => task.id === deliveredId)?.completionDelivery?.state, + "delivered", + ); + for (const id of siblingIds) + assert.equal( + reserved.subagents.find((task) => task.id === id)?.completionDelivery?.state, + "claimed", + ); + const throughSequence = yield* sink.latestSequence(); + const events = yield* sink.stream({ afterSequence }).pipe( + Stream.takeUntil((stored) => stored.sequence >= throughSequence), + Stream.runCollect, + ); + assert.equal( + events.filter( + (stored) => + stored.event.type === "run.updated" && + stored.event.payload.id === runId && + stored.event.payload.delegatedCompletion?.delivery != null, + ).length, + 1, + ); + const rebuilt = yield* Orchestrator.OrchestratorV2.pipe( + Effect.provide(Layer.fresh(makeTestLayer(Layer.succeed(SqlClient.SqlClient, sql)))), + ); + const recovered = yield* rebuilt.getThreadProjection(threadId); + assert.deepEqual(recovered.runs[0]?.delegatedCompletion, cohort); + for (const id of siblingIds) + assert.equal( + recovered.subagents.find((task) => task.id === id)?.completionDelivery?.state, + "claimed", + ); + }).pipe(Effect.provide(Layer.fresh(TestLayer))), + ); + + for (const trigger of [ + "policy change", + "recovery", + "accept before finalization", + "accept interrupted before finalization", + "settlement", + "user cancellation", + ] as const) { + it.effect(`handles cancelled delivery ownership via ${trigger}`, () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make(`thread:delegated-delivery-cancel-replan:${trigger}`); + const projectId = ProjectId.make("project:delegated-delivery-cancel-replan"); + const parentRunId = RunId.make("run:delegated-delivery-cancel-replan:parent"); + const deliveryRunId = RunId.make("run:delegated-delivery-cancel-replan:delivery"); + const parentRootNodeId = NodeId.make("node:delegated-delivery-cancel-replan:parent-root"); + const deliveryRootNodeId = NodeId.make( + "node:delegated-delivery-cancel-replan:delivery-root", + ); + const taskId = NodeId.make("node:delegated-delivery-cancel-replan:task"); + const messageId = MessageId.make(`message:delegated-delivery:${threadId}`); + + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId: parentRunId, + rootNodeId: parentRootNodeId, + taskId, + deliveryState: "claimed", + completionWake: "always", + deliveryTaskIds: [taskId], + parentStatus: "completed", + now, + }); + const finalizerEntered = yield* Deferred.make(); + const releaseFinalizer = yield* Deferred.make(); + const store = yield* ProjectionStore.ProjectionStoreV2; + const getThread = store.getThread; + const acceptBeforeFinalization = + trigger === "accept before finalization" || + trigger === "accept interrupted before finalization"; + const terminalStatus = + trigger === "accept interrupted before finalization" ? "interrupted" : "cancelled"; + const queuedCancellation = trigger === "settlement" || trigger === "user cancellation"; + if (acceptBeforeFinalization) { + let held = false; + Object.assign(store, { + getThread: (id: ThreadId) => + Effect.gen(function* () { + const thread = yield* getThread(id); + if (id === threadId && !held) { + const state = yield* store.getThreadProjection(id); + if ( + state.runs.some( + (run) => run.id === deliveryRunId && run.status === terminalStatus, + ) + ) { + held = true; + yield* Deferred.succeed(finalizerEntered, undefined); + yield* Deferred.await(releaseFinalizer); + } + } + return thread; + }), + }); + } + const beforeCancel = yield* eventSink.latestSequence(); + yield* eventSink.write({ + commandId: CommandId.make("command:delegated-delivery-cancel-replan"), + events: [ + { + id: EventId.make("event:delegated-delivery-cancel-replan:message"), + type: "message.updated", + threadId, + runId: deliveryRunId, + nodeId: deliveryRootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + createdBy: "agent", + creationSource: "server", + id: messageId, + threadId, + runId: deliveryRunId, + nodeId: deliveryRootNodeId, + role: "user", + text: `Delegated task ${taskId} reached a terminal state.`, + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + delegatedCompletion: { + parentRunId, + generation: 1, + taskIds: [taskId], + }, + }, + }, + { + id: EventId.make("event:delegated-delivery-cancel-replan:run"), + type: "run.updated", + threadId, + runId: deliveryRunId, + nodeId: deliveryRootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: deliveryRunId, + threadId, + ordinal: 2, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: ProviderThreadId.make( + `provider-thread:${String(threadId).replace("thread:", "")}`, + ), + userMessageId: messageId, + rootNodeId: deliveryRootNodeId, + activeAttemptId: null, + status: queuedCancellation ? "queued" : terminalStatus, + queueHeld: queuedCancellation, + requestedAt: now, + startedAt: queuedCancellation ? null : now, + completedAt: queuedCancellation ? null : now, + checkpointId: null, + contextHandoffId: null, + }, + }, + ], + }); + + if (queuedCancellation) { + if (trigger === "user cancellation") { + yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make("cancel-replan:user-cancel"), + threadId, + runId: deliveryRunId, + }); + } else { + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("cancel-replan:settle"), + threadId, + }); + } + const settled = yield* orchestrator.getThreadProjection(threadId); + assert.equal(settled.runs.find((run) => run.id === deliveryRunId)?.status, "cancelled"); + assert.equal( + settled.subagents.find((task) => task.id === taskId)?.completionDelivery?.state, + "disposed", + ); + assert.equal( + settled.runs.find((run) => run.id === parentRunId)?.delegatedCompletion?.disposition, + "disposed", + ); + yield* orchestrator.dispatch({ + type: "delegated_task.wake-policy", + commandId: CommandId.make("cancel-replan:policy-after-settle"), + parentThreadId: threadId, + taskId, + completionWake: "settled_only", + }); + yield* orchestrator.dispatch({ + type: "delegated_task.wake-policy", + commandId: CommandId.make("cancel-replan:automatic-upgrade"), + parentThreadId: threadId, + taskId, + completionWake: "always", + }); + const afterPolicy = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterPolicy.runs.find((run) => run.id === parentRunId)?.delegatedCompletion?.delivery, + null, + ); + assert.equal( + afterPolicy.subagents.find((task) => task.id === taskId)?.completionDelivery?.state, + "disposed", + ); + return; + } + if (acceptBeforeFinalization) { + yield* Effect.gen(function* () { + yield* Deferred.await(finalizerEntered); + const durable = yield* store.getThreadProjection(threadId); + assert.equal( + durable.runs.find((run) => run.id === deliveryRunId)?.status, + terminalStatus, + ); + assert.equal( + durable.runs.find((run) => run.id === parentRunId)?.delegatedCompletion?.delivery + ?.messageId, + messageId, + ); + const task = durable.subagents.find((row) => row.id === taskId)!; + const siblingId = NodeId.make("cancel-pre-finalize:pending-sibling"); + yield* eventSink.write({ + events: [ + { + id: EventId.make("cancel-pre-finalize:pending-sibling"), + type: "subagent.updated", + threadId, + runId: parentRunId, + occurredAt: now, + payload: { + ...task, + id: siblingId, + completionDelivery: { state: "pending", observedByRunId: null }, + }, + }, + ], + }); + yield* orchestrator.dispatch({ + type: "notification.delivery.accept", + commandId: CommandId.make("cancel-pre-finalize:accept"), + threadId, + messageId, + }); + const after = yield* store.getThreadProjection(threadId); + assert.equal( + after.subagents.find((row) => row.id === taskId)?.completionDelivery?.state, + "claimed", + ); + assert.equal( + after.subagents.find((row) => row.id === siblingId)?.completionDelivery?.state, + "pending", + ); + assert.deepEqual( + after.runs.find((run) => run.id === parentRunId)?.delegatedCompletion, + durable.runs.find((run) => run.id === parentRunId)?.delegatedCompletion, + ); + }).pipe( + Effect.ensuring(Deferred.succeed(releaseFinalizer, undefined)), + Effect.ensuring(Effect.sync(() => Object.assign(store, { getThread }))), + ); + } + if (acceptBeforeFinalization) return; + // Wait on the durable cohort-finalization event, not scheduler timing. + yield* eventSink.stream({ afterSequence: beforeCancel, eventType: "run.updated" }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && stored.event.payload.id === parentRunId, + ), + Stream.take(1), + Stream.runDrain, + ); + const afterCancel = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterCancel.subagents.find((candidate) => candidate.id === taskId)?.completionDelivery + ?.state, + "pending", + ); + assert.equal( + afterCancel.runs.find((candidate) => candidate.id === parentRunId)?.delegatedCompletion + ?.delivery, + null, + ); + + yield* orchestrator.dispatch({ + type: "notification.delivery.accept", + commandId: CommandId.make("command:cancel-replan:late-acceptance"), + threadId, + messageId, + }); + const afterLateAcceptance = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterLateAcceptance.runs.find((run) => run.id === parentRunId)?.delegatedCompletion + ?.delivery, + null, + ); + assert.equal( + afterLateAcceptance.subagents.find((task) => task.id === taskId)?.completionDelivery + ?.state, + "pending", + ); + assert.notInclude(yield* store.getRecoveryThreadIds("delegated-completions"), threadId); + if (trigger === "policy change") { + yield* orchestrator.dispatch({ + type: "delegated_task.wake-policy", + commandId: CommandId.make("command:delegated-delivery-cancel-replan:wake-policy"), + parentThreadId: threadId, + taskId, + completionWake: "settled_only", + }); + } else { + // Rebuild over the same database: cancellation must survive ordinary restart. + const sql = yield* SqlClient.SqlClient; + const otherRunId = RunId.make("cancel-replan:other-parent"); + const otherTaskId = NodeId.make("cancel-replan:other-task"); + const cancelledProjection = yield* orchestrator.getThreadProjection(threadId); + yield* eventSink.write({ + events: [ + { + id: EventId.make("cancel-replan:other-run"), + type: "run.updated", + threadId, + runId: otherRunId, + occurredAt: now, + payload: { + ...cancelledProjection.runs.find((run) => run.id === parentRunId)!, + id: otherRunId, + ordinal: 3, + delegatedCompletion: { + disposition: "open", + nextGeneration: 2, + delivery: { + generation: 1, + messageId: MessageId.make("cancel-replan:other-message"), + taskIds: [otherTaskId], + }, + }, + }, + }, + { + id: EventId.make("cancel-replan:other-task"), + type: "subagent.updated", + threadId, + runId: otherRunId, + occurredAt: now, + payload: { + ...cancelledProjection.subagents.find((task) => task.id === taskId)!, + id: otherTaskId, + runId: otherRunId, + completionDelivery: { state: "claimed", observedByRunId: null }, + }, + }, + ], + }); + assert.include(yield* store.getRecoveryThreadIds("delegated-completions"), threadId); + yield* Orchestrator.OrchestratorV2.pipe( + Effect.provide(Layer.fresh(makeTestLayer(Layer.succeed(SqlClient.SqlClient, sql)))), + ); + const afterRestart = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterRestart.runs.find((run) => run.id === parentRunId)?.delegatedCompletion?.delivery, + null, + ); + assert.equal( + afterRestart.subagents.find((task) => task.id === taskId)?.completionDelivery?.state, + "pending", + ); + yield* orchestrator.dispatch({ + type: "delegated_task.wake-policy", + commandId: CommandId.make("cancel-replan:explicit-after-restart"), + parentThreadId: threadId, + taskId, + completionWake: "settled_only", + }); + } + + const afterReplan = yield* orchestrator.getThreadProjection(threadId); + const parentRun = afterReplan.runs.find((candidate) => candidate.id === parentRunId); + const task = afterReplan.subagents.find((candidate) => candidate.id === taskId); + assert.equal(task?.completionDelivery?.state, "claimed"); + assert.isNotNull(parentRun?.delegatedCompletion?.delivery ?? null); + assert.deepEqual(parentRun?.delegatedCompletion?.delivery?.taskIds, [taskId]); + }).pipe(Effect.provide(Layer.fresh(TestLayer))), + ); + } + it.effect("does not automatically re-reserve a cancelled delivery", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make(`thread:delegated-delivery-cancel-replan:standalone`); + const projectId = ProjectId.make("project:delegated-delivery-cancel-replan"); + const parentRunId = RunId.make("run:delegated-delivery-cancel-replan:parent"); + const deliveryRunId = RunId.make("run:delegated-delivery-cancel-replan:delivery"); + const parentRootNodeId = NodeId.make("node:delegated-delivery-cancel-replan:parent-root"); + const deliveryRootNodeId = NodeId.make("node:delegated-delivery-cancel-replan:delivery-root"); + const taskId = NodeId.make("node:delegated-delivery-cancel-replan:task"); + const messageId = MessageId.make(`message:delegated-delivery:${threadId}`); + + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId: parentRunId, + rootNodeId: parentRootNodeId, + taskId, + deliveryState: "claimed", + completionWake: "always", + deliveryTaskIds: [taskId], + parentStatus: "completed", + now, + }); + const beforeCancel = yield* eventSink.latestSequence(); + yield* eventSink.write({ + commandId: CommandId.make("command:delegated-delivery-cancel-replan"), + events: [ + { + id: EventId.make("event:delegated-delivery-cancel-replan:message"), + type: "message.updated", + threadId, + runId: deliveryRunId, + nodeId: deliveryRootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + createdBy: "agent", + creationSource: "server", + id: messageId, + threadId, + runId: deliveryRunId, + nodeId: deliveryRootNodeId, + role: "user", + text: `Delegated task ${taskId} reached a terminal state.`, + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + delegatedCompletion: { + parentRunId, + generation: 1, + taskIds: [taskId], + }, + }, + }, + { + id: EventId.make("event:delegated-delivery-cancel-replan:run"), + type: "run.updated", + threadId, + runId: deliveryRunId, + nodeId: deliveryRootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: deliveryRunId, + threadId, + ordinal: 2, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: ProviderThreadId.make( + `provider-thread:${String(threadId).replace("thread:", "")}`, + ), + userMessageId: messageId, + rootNodeId: deliveryRootNodeId, + activeAttemptId: null, + status: "cancelled", + queueHeld: false, + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }, + }, + ], + }); + + // Wait on the durable cohort-finalization event, not scheduler timing. + yield* eventSink.stream({ afterSequence: beforeCancel, eventType: "run.updated" }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && stored.event.payload.id === parentRunId, + ), + Stream.take(1), + Stream.runDrain, + ); + const afterCancel = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterCancel.subagents.find((candidate) => candidate.id === taskId)?.completionDelivery + ?.state, + "pending", + ); + assert.equal( + afterCancel.runs.find((candidate) => candidate.id === parentRunId)?.delegatedCompletion + ?.delivery, + null, + ); + }).pipe(Effect.provide(Layer.fresh(TestLayer))), + ); + + it.effect("overlapping explicit rearms keep policy and reservation ownership", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const store = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("review-lock-probe"); + const runId = RunId.make("review-lock-probe:parent"); + const deliveredId = NodeId.make("review-lock-probe:delivered"); + const siblingX = NodeId.make("review-lock-probe:x"); + const siblingY = NodeId.make("review-lock-probe:y"); + const controlId = NodeId.make("review-lock-probe:control"); + yield* seedParentWithTerminalTask({ + threadId, + runId, + taskId: deliveredId, + now, + projectId: ProjectId.make("review-lock-probe:project"), + rootNodeId: NodeId.make("review-lock-probe:root"), + completionWake: "always", + deliveryState: "delivered", + parentStatus: "completed", + }); + const seed = yield* orchestrator.getThreadProjection(threadId); + yield* sink.write({ + events: [siblingX, siblingY, controlId].map((id) => ({ + id: EventId.make(`review-lock-probe:seed:${id}`), + type: "subagent.updated" as const, + threadId, + runId, + occurredAt: now, + payload: { + ...seed.subagents[0]!, + id, + completionWake: "always" as const, + completionDelivery: { + state: id === controlId ? ("delivered" as const) : ("pending" as const), + observedByRunId: null, + }, + }, + })), + }); + // Pause after the first snapshot, before reservation planning finishes. + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const getMessageCount = store.getMessageCount; + let calls = 0; + Object.assign(store, { + getMessageCount: (id: ThreadId) => + Effect.gen(function* () { + if (id === threadId && calls++ === 0) { + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + } + return yield* getMessageCount(id); + }), + }); + const afterSequence = yield* sink.latestSequence(); + const first = yield* orchestrator + .dispatch({ + type: "delegated_task.wake-policy", + commandId: CommandId.make("review-lock-probe:first"), + parentThreadId: threadId, + taskId: controlId, + completionWake: "settled_only", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(entered); + const second = yield* orchestrator + .dispatch({ + type: "delegated_task.wake-policy", + commandId: CommandId.make("review-lock-probe:second"), + parentThreadId: threadId, + taskId: siblingX, + completionWake: "settled_only", + }) + .pipe(Effect.forkChild); + yield* TestClock.adjust(0); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + Object.assign(store, { getMessageCount }); + const throughSequence = yield* sink.latestSequence(); + const events = yield* sink.stream({ afterSequence }).pipe( + Stream.takeUntil((stored) => stored.sequence >= throughSequence), + Stream.runCollect, + ); + const reservations = Array.from(events).flatMap((stored) => + stored.event.type === "run.updated" && + stored.event.payload.id === runId && + stored.event.payload.delegatedCompletion?.delivery != null + ? [stored.event.payload.delegatedCompletion.delivery] + : [], + ); + const after = yield* orchestrator.getThreadProjection(threadId); + const x = after.subagents.find((task) => task.id === siblingX); + const cohort = after.runs.find((run) => run.id === runId)?.delegatedCompletion; + assert.equal(x?.completionWake, "settled_only", "second rearm's committed policy was lost"); + assert.equal(reservations.length, 1, "more than one reservation was written"); + assert.deepEqual(cohort?.delivery?.taskIds, [siblingX, siblingY].toSorted()); + }).pipe(Effect.provide(Layer.fresh(TestLayer))), + ); + + for (const guard of ["in-flight delivery", "live parent", "disposed cohort"] as const) { + it.effect(`explicit rearm preserves the ${guard}`, () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make(`rearm-guard:${guard}`); + const runId = RunId.make(`${threadId}:parent`); + const taskId = NodeId.make(`${threadId}:claimed`); + const siblingId = NodeId.make(`${threadId}:pending`); + const controlId = NodeId.make(`${threadId}:control`); + yield* seedParentWithTerminalTask({ + threadId, + runId, + taskId, + now, + projectId: ProjectId.make(`${threadId}:project`), + rootNodeId: NodeId.make(`${threadId}:root`), + parentStatus: guard === "live parent" ? "running" : "completed", + completionWake: "always", + deliveryState: guard === "in-flight delivery" ? "claimed" : "delivered", + ...(guard === "in-flight delivery" ? { deliveryTaskIds: [taskId] } : {}), + }); + const seed = yield* orchestrator.getThreadProjection(threadId); + const deliveryRun = { ...seed.runs[0]! }; + delete deliveryRun.delegatedCompletion; + yield* sink.write({ + events: [ + ...(guard === "in-flight delivery" + ? [ + { + id: EventId.make(`${threadId}:in-flight`), + type: "run.updated" as const, + threadId, + runId: RunId.make(`${threadId}:delivery`), + occurredAt: now, + payload: { + ...deliveryRun, + id: RunId.make(`${threadId}:delivery`), + ordinal: 2, + status: "running" as const, + userMessageId: seed.runs[0]!.delegatedCompletion!.delivery!.messageId, + }, + }, + ] + : []), + ...[siblingId, controlId].map((id) => ({ + id: EventId.make(`seed:${id}`), + type: "subagent.updated" as const, + threadId, + runId, + occurredAt: now, + payload: { + ...seed.subagents[0]!, + id, + completionWake: + id === siblingId && guard === "live parent" + ? ("settled_only" as const) + : ("always" as const), + completionDelivery: { + state: id === siblingId ? ("pending" as const) : ("delivered" as const), + observedByRunId: null, + }, + }, + })), + ...(guard === "disposed cohort" + ? [ + { + id: EventId.make(`${threadId}:dispose`), + type: "run.updated" as const, + threadId, + runId, + occurredAt: now, + payload: { + ...seed.runs[0]!, + delegatedCompletion: { + ...seed.runs[0]!.delegatedCompletion!, + disposition: "disposed" as const, + }, + }, + }, + ] + : []), + ], + }); + const before = yield* orchestrator.getThreadProjection(threadId); + yield* orchestrator.dispatch({ + type: "delegated_task.wake-policy", + commandId: CommandId.make(`${threadId}:rearm`), + parentThreadId: threadId, + taskId: controlId, + completionWake: "settled_only", + }); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + after.runs.find((run) => run.id === runId)?.delegatedCompletion, + before.runs.find((run) => run.id === runId)?.delegatedCompletion, + ); + assert.equal( + after.subagents.find((task) => task.id === siblingId)?.completionDelivery?.state, + "pending", + ); + assert.deepEqual( + after.subagents.find((task) => task.id === taskId)?.completionDelivery, + before.subagents.find((task) => task.id === taskId)?.completionDelivery, + ); + }).pipe(Effect.provide(Layer.fresh(TestLayer))), + ); + } }); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 7d28d5caef84..fcaa2d6d1c16 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -693,13 +693,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); - const mapDelegatedCompletionError = (effect: Effect.Effect) => + const mapDelegatedCompletionError = ( + effect: Effect.Effect, + command?: Pick, + ) => effect.pipe( Effect.mapError( (cause) => new OrchestratorDispatchError({ - commandId: CommandId.make("command:system:delegated-completion-delivery"), - commandType: "delegated_task.completion-delivery", + commandId: + command?.commandId ?? CommandId.make("command:system:delegated-completion-delivery"), + commandType: command?.type ?? "delegated_task.completion-delivery", cause, }), ), @@ -1045,6 +1049,84 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); + const reservePendingDelegatedCompletionDelivery = Effect.fnUntraced(function* ( + projection: Pick< + OrchestrationV2ThreadProjection, + "thread" | "runs" | "subagents" | "providerTurns" + >, + parentRun: OrchestrationV2Run, + emitEvent: ReturnType, + command: OrchestrationV2Command, + ) { + const threadId = projection.thread.id; + const cohort = parentRun.delegatedCompletion; + if ( + cohort === undefined || + cohort.disposition !== "open" || + cohort.delivery !== null || + projection.thread.archivedAt !== null || + projection.thread.deletedAt !== null + ) + return; + const parentIsLive = hasLiveRun(projection); + const pendingTasks = projection.subagents.filter( + (task) => + task.origin === "app_owned" && + task.runId === parentRun.id && + isTerminalDelegatedTaskStatus(task.status) && + task.completionDelivery?.state === "pending" && + (!parentIsLive || task.completionWake === "always"), + ); + if (pendingTasks.length === 0) return; + const now = yield* DateTime.now; + const messageId = yield* mapDelegatedCompletionError( + idAllocator.allocate.message({ + threadId, + ordinal: + (yield* mapDelegatedCompletionError(projectionStore.getMessageCount(threadId), command)) + + 1, + }), + command, + ); + for (const event of [ + ...pendingTasks.map((task) => ({ + type: "subagent.updated" as const, + threadId, + ...(task.runId === null ? {} : { runId: task.runId }), + nodeId: task.id, + driver: task.driver, + providerInstanceId: task.providerInstanceId, + occurredAt: now, + payload: { + ...task, + completionDelivery: { state: "claimed" as const, observedByRunId: null }, + updatedAt: now, + }, + })), + { + type: "run.updated" as const, + threadId, + runId: parentRun.id, + ...(parentRun.rootNodeId === null ? {} : { nodeId: parentRun.rootNodeId }), + providerInstanceId: parentRun.providerInstanceId, + occurredAt: now, + payload: { + ...parentRun, + delegatedCompletion: { + ...cohort, + nextGeneration: cohort.nextGeneration + 1, + delivery: { + generation: cohort.nextGeneration, + messageId, + taskIds: pendingTasks.map((task) => task.id), + }, + }, + }, + }, + ]) + yield* emitEvent(event); + }); + const offerDelegatedCompletionDeliveries = (threadId: ThreadId) => Effect.gen(function* () { const projection = yield* projectionStore.getThreadRecords(threadId, ["runs"]); @@ -6677,18 +6759,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio completionWake: command.completionWake, updatedAt: now, }; - // A non-terminal task needs no offer here: finalize reads the upgraded - // policy when the child terminalizes. Both writers hold this parent lock, - // so a terminal task means finalize already committed the terminal row and - // already made its offer decision under the pre-upgrade policy: under - // settled_only it offered iff the parent had no live run. Plan a delivery - // only when the parent has a live run now, which is precisely the case - // where finalize skipped. The mailbox steers a capable active session or - // queues behind that run. When the parent is not live, finalize already - // offered and a second - // offer would wake the parent twice. (If the parent settled in between, - // this skips a wake that finalize also skipped; a missed wake is cheaper - // than a duplicate one, and the result is already in the projection.) + // Preserve live-parent batching, then reserve other pending cohorts from + // the resulting plan. Non-terminal tasks use the policy when they finish. + // A settled_only result finalized while the parent ran still gets no wake if the parent finished before this upgrade. const parentRun = task.runId === null ? undefined @@ -6703,6 +6776,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio task: updatedTask, updatedTask, now, + command, }) : undefined; yield* emitEvent({ @@ -6715,10 +6789,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio occurredAt: now, payload: completionPlan?.task ?? updatedTask, }); - if (completionPlan === undefined) { - return; - } - if (completionPlan.parentRun !== undefined) { + if (completionPlan?.parentRun !== undefined) { yield* emitEvent({ type: "run.updated", threadId: command.parentThreadId, @@ -6731,7 +6802,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio payload: completionPlan.parentRun, }); } - if (completionPlan.message !== undefined) { + if (completionPlan?.message !== undefined) { yield* emitEvent({ type: "message.updated", threadId: command.parentThreadId, @@ -6746,6 +6817,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio payload: completionPlan.message, }); } + // Reserve from the planned policy state so policy, claims and generation commit together. + const pending = { + ...parentProjection, + subagents: parentProjection.subagents.map((candidate) => + candidate.id === task.id ? (completionPlan?.task ?? updatedTask) : candidate, + ), + runs: parentProjection.runs.map((candidate) => + candidate.id === completionPlan?.parentRun?.id ? completionPlan.parentRun : candidate, + ), + }; + for (const run of pending.runs) + yield* reservePendingDelegatedCompletionDelivery(pending, run, emitEvent, command); }); const dispatchCreatedThreadRecord = Effect.fn("orchestrationV2.dispatch.createdThreadRecord")( @@ -8407,6 +8490,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly task: OrchestrationV2Subagent; readonly updatedTask: OrchestrationV2Subagent; readonly now: DateTime.Utc; + readonly command?: Pick; }) { const taskDelivery = input.updatedTask.completionDelivery; // delivered ownership has already settled through a completed wake run. @@ -8560,8 +8644,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ordinal: (yield* mapDelegatedCompletionError( projectionStore.getMessageCount(input.parentRun.threadId), + input.command, )) + 1, }), + input.command, ); const nextCohort = { disposition: "open" as const, @@ -8950,6 +9036,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cohort.disposition === "open" && projection.thread.archivedAt === null && projection.thread.deletedAt === null && + deliveryRun.status !== "cancelled" && pendingTaskIds.length > 0; const nextDelivery = canReserveFollowUp ? { @@ -9027,6 +9114,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) { const projection = yield* getProjectionWithPendingEvents(command.threadId, events); const message = projection.messages.find((row) => row.id === command.messageId); + const deliveryRun = projection.runs.find((run) => run.id === message?.runId); const ownership = message?.delegatedCompletion; const parentRun = projection.runs.find((run) => run.id === ownership?.parentRunId); const cohort = parentRun?.delegatedCompletion; @@ -9039,7 +9127,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cohort.disposition !== "open" || delivery == null || delivery.messageId !== command.messageId || - delivery.generation !== ownership?.generation + delivery.generation !== ownership?.generation || + deliveryRun === undefined || + deliveryRun.status === "cancelled" || + deliveryRun.status === "interrupted" ) { yield* emitEvent({ type: "thread.metadata-updated", @@ -9672,20 +9763,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio for (const runId of terminalDeliveryRunIds) { yield* finalizeDelegatedCompletionDelivery(threadId, runId); } - const refreshed = - terminalDeliveryRunIds.length === 0 - ? projection - : yield* projectionStore.getThreadRecords(threadId, ["runs", "messages"], { - messageRoles: ["user"], - }); - for (const run of refreshed.runs) { - if ( - run.delegatedCompletion?.delivery !== null && - run.delegatedCompletion !== undefined - ) { - yield* offerDelegatedCompletionDelivery(threadId, run.id); - } - } + yield* offerDelegatedCompletionDeliveries(threadId); }), ) .pipe( From b232d1c5e8c04ec8587f54dc83ca36dafdf25fa9 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Fri, 25 Sep 2026 08:00:28 -0400 Subject: [PATCH 19/34] fix(orchestrator): Recover delegated completions stranded at settlement --- ...edCompletionSettlement.integration.test.ts | 1633 +++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 119 +- .../ProviderContinuationService.ts | 4 + 3 files changed, 1749 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/orchestration-v2/DelegatedCompletionSettlement.integration.test.ts diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionSettlement.integration.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionSettlement.integration.test.ts new file mode 100644 index 000000000000..9bf7be5f38fa --- /dev/null +++ b/apps/server/src/orchestration-v2/DelegatedCompletionSettlement.integration.test.ts @@ -0,0 +1,1633 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + NodeId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + RunId, + ThreadId, + type OrchestrationV2DomainEvent, + type OrchestrationV2Run, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; +import { EventSinkV2 } from "./EventSink.ts"; +import { layer as idAllocatorLayer } from "./IdAllocator.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import { ProjectionStoreV2 } from "./ProjectionStore.ts"; +import { + ProviderAdapterSteerRunError, + type ProviderAdapterV2Event, + type ProviderAdapterV2Shape, + type ProviderAdapterV2TurnInput, +} from "./ProviderAdapter.ts"; +import { makeSingleLayer } from "./ProviderAdapterRegistry.ts"; +import { + ProviderContinuationRequests, + type ProviderContinuationRequest, +} from "./ProviderContinuationRequests.ts"; +import { workerLive as continuationWorker } from "./ProviderContinuationService.ts"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +// A provider transcript cannot reject steering while the orchestrator still +// projects the turn as running. These cases keep the real orchestrator, SQLite +// projection, outbox retries and continuation worker, and control only that +// adapter boundary. Retry time is virtual; ordering uses event barriers. + +const driver = ProviderDriverKind.make("codex"); +const instanceId = ProviderInstanceId.make("codex"); +const runningSelection = { instanceId, model: "running-model" }; +const desiredSelection = { + instanceId, + model: "next-turn-model", + options: [{ id: "effort", value: "high" }], +}; +const desiredSelectionMessageId = MessageId.make("message:desired-selection"); +const threadId = ThreadId.make("thread:completion-settlement"); +const markerThreadId = ThreadId.make("thread:completion-settlement-marker"); +const markerRunId = RunId.make("run:completion-settlement-marker"); + +interface AdapterControls { + readonly events: Queue.Queue; + readonly started: Array; + readonly steered: Array; + steer: "reject" | "accept"; + sessionOpens: number; + interrupts: number; +} + +it.effect("recovers a completion whose steering retries exhausted before the parent settled", () => + runScenario("exhausted-before-terminal", (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + yield* h.saveDesiredSelection(parent.runId); + const { messageId, taskId } = yield* h.reserveSteer(parent.runId); + yield* h.exhaustSteering(); + assert.deepEqual(h.controls.steered, [messageId, messageId, messageId, messageId, messageId]); + const stalled = yield* h.projection(); + assert.equal(stalled.runs.length, 1); + assert.equal(h.task(stalled, taskId)?.completionDelivery?.state, "claimed"); + const siblingIds = yield* h.addPendingSiblings(parent.runId, 3); + const userMessages = stalled.messages.filter((message) => message.createdBy === "user"); + + yield* h.finishTurn(parent, "completed"); + yield* h.settled(); + assert.deepEqual( + h.offers.map((offer) => offer.delegatedCompletion), + [{ parentRunId: parent.runId, generation: 1, messageId }], + "the terminal boundary reoffers the exhausted reservation", + ); + + yield* h.startContinuationWorker(); + const wake = yield* h.awaitDispatchedTurn(2); + assert.equal(wake.message.messageId, messageId); + assert.include(wake.message.text, String(taskId)); + for (const siblingId of siblingIds) assert.notInclude(wake.message.text, String(siblingId)); + assert.deepEqual(wake.modelSelection, desiredSelection); + const recovered = yield* h.projection(); + assert.deepEqual(recovered.thread.modelSelection, desiredSelection); + assert.deepEqual(h.run(recovered, parent.runId)?.modelSelection, runningSelection); + assert.equal(recovered.messages.filter((message) => message.id === messageId).length, 1); + assert.equal( + recovered.messages.find((message) => message.id === messageId)?.runId, + wake.runId, + ); + assert.equal(recovered.turnItems.filter((item) => item.type === "notification").length, 1); + + // A duplicate terminal event and a duplicate request reuse the same identity. + yield* h.repeatRunEvent(parent.runId); + yield* h.settled(); + assert.equal(h.offers.length, 1); + yield* Queue.offer(h.requests, h.offers[0]!); + + yield* h.finishTurn(wake, "completed"); + const siblingsWake = yield* h.awaitDispatchedTurn(3); + assert.equal(h.controls.started.length, 3); + assert.equal(h.offers[1]?.delegatedCompletion?.generation, 2); + assert.notEqual(siblingsWake.message.messageId, messageId); + for (const siblingId of siblingIds) + assert.include(siblingsWake.message.text, String(siblingId)); + assert.notInclude(siblingsWake.message.text, String(taskId)); + assert.deepEqual(siblingsWake.modelSelection, desiredSelection); + + yield* h.finishTurn(siblingsWake, "completed"); + yield* h.settled(); + const delivered = yield* h.projection(); + const cohort = h.run(delivered, parent.runId)?.delegatedCompletion; + assert.equal(cohort?.delivery, null); + assert.deepEqual( + delivered.subagents.map((task) => task.completionDelivery?.state), + ["delivered", "delivered", "delivered", "delivered"], + ); + assert.deepEqual( + delivered.messages.filter((message) => message.createdBy === "user"), + userMessages, + "recovery needs no new user input", + ); + assert.deepEqual(delivered.thread.modelSelection, desiredSelection); + assert.equal(h.controls.sessionOpens, 1); + assert.equal(h.controls.interrupts, 0); + + // Late or repeated acknowledgments of the recovered delivery change nothing. + yield* h.orchestrator.dispatch({ + type: "notification.delivery.accept", + commandId: CommandId.make("late-acceptance"), + threadId, + messageId, + }); + const afterLateAcceptance = yield* h.projection(); + assert.deepEqual(afterLateAcceptance.subagents, delivered.subagents); + assert.deepEqual(afterLateAcceptance.runs, delivered.runs); + for (const task of delivered.subagents) { + yield* h.orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make(`read-result:${task.id}`), + parentThreadId: threadId, + taskId: task.id, + observedByRunId: siblingsWake.runId, + }); + } + yield* h.repeatRunEvent(parent.runId); + yield* h.settled(); + assert.equal(h.offers.length, 2); + assert.equal(h.controls.started.length, 3); + assert.isTrue( + (yield* h.projection()).subagents.every( + (task) => task.completionDelivery?.state === "acknowledged", + ), + ); + }), + ), +); + +it.effect("recovers when settlement is visible before retries exhaust", () => + runScenario("terminal-before-exhaustion", (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const { messageId } = yield* h.reserveSteer(parent.runId); + yield* h.worker.drain(); + assert.deepEqual(h.controls.steered, [messageId]); + assert.isTrue(Option.isSome(yield* h.worker.nextClaimableAt)); + + // The provider ends the turn without a T3 Stop, so the cohort stays open. + // The interrupted run's later checkpoint update repeats the offer, which + // the continuation worker drops once the steer is dispatched. + yield* h.finishTurn(parent, "interrupted"); + yield* h.settled(); + assert.isNotEmpty(h.offers); + for (const offer of h.offers) { + assert.deepEqual(offer.delegatedCompletion, { + parentRunId: parent.runId, + generation: 1, + messageId, + }); + } + yield* h.exhaustSteering(); + assert.deepEqual(h.controls.steered, [messageId], "later retries never reach the provider"); + + yield* h.startContinuationWorker(); + const wake = yield* h.awaitDispatchedTurn(2); + assert.equal(wake.message.messageId, messageId); + const recovered = yield* h.projection(); + assert.equal(recovered.messages.filter((message) => message.id === messageId).length, 1); + assert.equal(recovered.turnItems.filter((item) => item.type === "notification").length, 1); + assert.equal(h.controls.started.length, 2); + }), + ), +); + +it.effect("recovers the reservation of the earlier cohort that owned the steer", () => + runScenario("earlier-cohort", (h) => + Effect.gen(function* () { + const earlier = yield* h.startRun("message:earlier"); + yield* h.finishTurn(earlier, "completed"); + yield* h.settled(); + const parent = yield* h.startRun("message:parent"); + const { messageId, taskId } = yield* h.reserveSteer(earlier.runId); + yield* h.exhaustSteering(); + const stalled = yield* h.projection(); + assert.equal( + stalled.messages.find((message) => message.id === messageId)?.runId, + parent.runId, + ); + + yield* h.finishTurn(parent, "completed"); + yield* h.settled(); + assert.deepEqual( + h.offers.map((offer) => offer.delegatedCompletion), + [{ parentRunId: earlier.runId, generation: 1, messageId }], + ); + + yield* h.startContinuationWorker(); + const wake = yield* h.awaitDispatchedTurn(3); + assert.equal(wake.message.messageId, messageId); + yield* h.finishTurn(wake, "completed"); + yield* h.settled(); + const delivered = yield* h.projection(); + assert.equal(h.task(delivered, taskId)?.completionDelivery?.state, "delivered"); + assert.equal(h.run(delivered, earlier.runId)?.delegatedCompletion?.delivery, null); + assert.isUndefined(h.run(delivered, parent.runId)?.delegatedCompletion); + }), + ), +); + +it.effect("does not reoffer a steer the provider accepted before the parent settled", () => + runScenario("accepted-before-terminal", (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const { messageId, taskId } = yield* h.reserveSteer(parent.runId); + const siblingIds = yield* h.addPendingSiblings(parent.runId, 1); + h.controls.steer = "accept"; + yield* h.worker.drain(); + assert.deepEqual(h.controls.steered, [messageId]); + const accepted = yield* h.projection(); + assert.equal(h.task(accepted, taskId)?.completionDelivery?.state, "delivered"); + const successor = h.run(accepted, parent.runId)?.delegatedCompletion?.delivery; + assert.deepEqual(successor?.taskIds, siblingIds); + assert.notEqual(successor?.messageId, messageId); + const offersBeforeTerminal = h.offers.length; + + yield* h.finishTurn(parent, "completed"); + assert.isAtLeast(yield* h.settled(), 1, "the terminal handler ran"); + assert.equal( + h.offers.length, + offersBeforeTerminal, + "an accepted steer does not reoffer the successor reservation", + ); + const settled = yield* h.projection(); + assert.equal(h.task(settled, taskId)?.completionDelivery?.state, "delivered"); + assert.deepEqual(h.run(settled, parent.runId)?.delegatedCompletion?.delivery, successor); + }), + ), +); + +it.effect("collapses duplicate and stale recovery offers into one wake", () => + runScenario("duplicate-stale-offers", (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const { messageId, taskId } = yield* h.reserveSteer(parent.runId); + yield* h.exhaustSteering(); + yield* h.finishTurn(parent, "completed"); + yield* h.settled(); + yield* h.repeatRunEvent(parent.runId); + yield* h.settled(); + const recovery = { parentRunId: parent.runId, generation: 1, messageId }; + assert.deepEqual( + h.offers.map((offer) => offer.delegatedCompletion), + [recovery, recovery], + "each terminal event reoffers the same reservation identity", + ); + + // The worker handles requests serially, so a third read proves the + // duplicate before it was handled. + yield* h.startContinuationWorker(); + const wake = yield* h.awaitDispatchedTurn(2); + yield* Queue.offer(h.requests, h.offers[0]!); + yield* h.awaitContinuationReads(3); + assert.equal(h.dispatches.length, 1); + assert.equal(wake.message.messageId, messageId); + const recovered = yield* h.projection(); + assert.equal(recovered.messages.filter((message) => message.id === messageId).length, 1); + assert.equal(recovered.turnItems.filter((item) => item.type === "notification").length, 1); + + yield* h.finishTurn(wake, "completed"); + yield* h.settled(); + const delivered = yield* h.projection(); + assert.equal(h.task(delivered, taskId)?.completionDelivery?.state, "delivered"); + assert.equal(h.run(delivered, parent.runId)?.delegatedCompletion?.delivery, null); + yield* Queue.offer(h.requests, h.offers[0]!); + yield* Queue.offer(h.requests, h.offers[0]!); + yield* h.awaitContinuationReads(2); + assert.equal(h.dispatches.length, 1, "a settled generation is never dispatched again"); + assert.equal(h.controls.started.length, 2); + }), + ), +); + +it.effect("does not revive an exhausted steer when its parent is rolled back", () => + runScenario("rollback", (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const { messageId, taskId } = yield* h.reserveSteer(parent.runId); + yield* h.exhaustSteering(); + const queuedMessageId = MessageId.make("message:queued-user-work"); + yield* h.orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("queued-user-work"), + threadId, + messageId: queuedMessageId, + text: "explicit queued user work", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const before = yield* h.projection(); + const parentRun = h.run(before, parent.runId)!; + yield* h.rollBack(parent.runId); + assert.isAtLeast(yield* h.settled(), 1, "the terminal handler ran"); + assert.deepEqual(h.offers, []); + const rolledBack = yield* h.projection(); + assert.equal(h.run(rolledBack, parent.runId)?.status, "rolled_back"); + assert.deepEqual( + h.run(rolledBack, parent.runId)?.delegatedCompletion, + parentRun.delegatedCompletion, + ); + assert.equal(h.task(rolledBack, taskId)?.completionDelivery?.state, "claimed"); + assert.equal( + rolledBack.messages.find((message) => message.id === messageId)?.runId, + parent.runId, + ); + assert.equal( + rolledBack.runs.find((run) => run.userMessageId === queuedMessageId)?.status, + "starting", + "ordinary queue promotion still follows the terminal boundary", + ); + }), + ), +); + +it.effect("drops a queued recovery offer whose parent is rolled back before dispatch", () => + runScenario("rollback-after-offer", (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const { messageId, taskId } = yield* h.reserveSteer(parent.runId); + yield* h.exhaustSteering(); + yield* h.finishTurn(parent, "completed"); + yield* h.settled(); + assert.deepEqual( + h.offers.map((offer) => offer.delegatedCompletion), + [{ parentRunId: parent.runId, generation: 1, messageId }], + ); + const completed = yield* h.projection(); + + yield* h.rollBack(parent.runId); + assert.isAtLeast(yield* h.settled(), 1, "the terminal handler ran"); + assert.equal(h.offers.length, 1, "the rollback event does not offer again"); + assert.equal( + yield* h.dispatchStaleCompletion(parent.runId, messageId, [taskId]), + "Delegated completion delivery is no longer dispatchable.", + ); + + // The worker handles requests serially, so reading a repeated request + // proves the queued offer was handled. + yield* h.startContinuationWorker(); + yield* Queue.offer(h.requests, h.offers[0]!); + yield* h.awaitContinuationReads(2); + assert.deepEqual(h.dispatchAttempts, [], "the queued offer is dropped, not dispatched"); + assert.equal(h.offers.length, 1, "the dropped offer is not retried"); + assert.equal(h.controls.started.length, 1); + const rolledBack = yield* h.projection(); + assert.deepEqual( + rolledBack.runs.map((run) => run.id), + completed.runs.map((run) => run.id), + ); + assert.equal(h.run(rolledBack, parent.runId)?.status, "rolled_back"); + assert.deepEqual( + h.run(rolledBack, parent.runId)?.delegatedCompletion, + h.run(completed, parent.runId)?.delegatedCompletion, + ); + assert.equal(h.task(rolledBack, taskId)?.completionDelivery?.state, "claimed"); + assert.equal( + rolledBack.messages.find((message) => message.id === messageId)?.runId, + parent.runId, + ); + + // Reading the result later does not release the discarded cohort to a sibling. + const [siblingId] = yield* h.addPendingSiblings(parent.runId, 1); + yield* h.orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make("read-after-rollback"), + parentThreadId: threadId, + taskId, + observedByRunId: parent.runId, + }); + const read = yield* h.projection(); + assert.deepEqual(h.run(read, parent.runId)?.delegatedCompletion?.delivery, { + generation: 1, + messageId, + taskIds: [], + }); + assert.equal(h.task(read, siblingId!)?.completionDelivery?.state, "pending"); + assert.equal(h.offers.length, 1); + assert.equal(h.controls.started.length, 1); + }), + ), +); + +it.effect("drops a queued recovery offer whose result was read before dispatch", () => + runScenario("acknowledged-after-offer", (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const { messageId, taskId } = yield* h.reserveSteer(parent.runId); + yield* h.exhaustSteering(); + yield* h.finishTurn(parent, "completed"); + yield* h.settled(); + assert.deepEqual( + h.offers.map((offer) => offer.delegatedCompletion), + [{ parentRunId: parent.runId, generation: 1, messageId }], + ); + + yield* h.orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make("read-after-offer"), + parentThreadId: threadId, + taskId, + observedByRunId: parent.runId, + }); + const acknowledged = yield* h.projection(); + assert.equal( + h.run(acknowledged, parent.runId)?.delegatedCompletion?.delivery, + null, + "a settled steer with no unread result releases its reservation", + ); + assert.equal(h.offers.length, 1, "with no pending sibling nothing new is offered"); + assert.equal( + yield* h.dispatchStaleCompletion(parent.runId, messageId, [taskId]), + "Delegated completion delivery is no longer dispatchable.", + ); + + yield* h.startContinuationWorker(); + yield* Queue.offer(h.requests, h.offers[0]!); + yield* h.awaitContinuationReads(2); + assert.deepEqual(h.dispatchAttempts, [], "an emptied reservation starts no wake"); + assert.equal(h.offers.length, 1, "the dropped offer is not retried"); + assert.equal(h.controls.started.length, 1); + const read = yield* h.projection(); + assert.deepEqual( + read.runs.map((run) => run.id), + acknowledged.runs.map((run) => run.id), + ); + assert.deepEqual( + read.messages.find((message) => message.id === messageId), + acknowledged.messages.find((message) => message.id === messageId), + ); + assert.equal(h.task(read, taskId)?.completionDelivery?.state, "acknowledged"); + }), + ), +); + +it.effect("delivers a later sibling once after the settled steer's result was read", () => + runScenario("read-then-later-sibling", (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const sibling = yield* h.requestChild(parent); + const { messageId, taskId } = yield* h.reserveSteer(parent.runId); + yield* h.exhaustSteering(); + yield* h.finishTurn(parent, "completed"); + yield* h.settled(); + assert.deepEqual( + h.offers.map((offer) => offer.delegatedCompletion), + [{ parentRunId: parent.runId, generation: 1, messageId }], + ); + + yield* h.orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make("read-settled-steer"), + parentThreadId: threadId, + taskId, + observedByRunId: parent.runId, + }); + assert.equal(h.run(yield* h.projection(), parent.runId)?.delegatedCompletion?.delivery, null); + assert.equal(h.offers.length, 1); + + yield* h.finishTurn(sibling.turn, "completed"); + yield* h.settled(); + const reserved = yield* h.projection(); + const delivery = h.run(reserved, parent.runId)?.delegatedCompletion?.delivery; + assert.equal(delivery?.generation, 2); + assert.deepEqual(delivery?.taskIds, [sibling.taskId]); + assert.equal(h.task(reserved, sibling.taskId)?.completionDelivery?.state, "claimed"); + + // The stale recovery offer is read first and dropped; only the sibling wakes. + yield* h.startContinuationWorker(); + const wake = yield* h.awaitDispatchedTurn(3); + assert.equal(h.dispatches.length, 1); + assert.equal(wake.message.messageId, delivery?.messageId); + assert.include(wake.message.text, String(sibling.taskId)); + assert.notInclude(wake.message.text, String(taskId)); + yield* h.finishTurn(wake, "completed"); + yield* h.settled(); + yield* h.repeatRunEvent(parent.runId); + yield* h.settled(); + + const delivered = yield* h.projection(); + assert.equal(h.task(delivered, sibling.taskId)?.completionDelivery?.state, "delivered"); + assert.equal(h.task(delivered, taskId)?.completionDelivery?.state, "acknowledged"); + assert.equal(h.run(delivered, parent.runId)?.delegatedCompletion?.delivery, null); + assert.equal(h.offers.length, 2); + assert.equal(h.dispatches.length, 1); + assert.equal(h.controls.started.length, 3, "one wake, and no empty wake"); + }), + ), +); + +for (const release of ["acknowledge", "dispose"] as const) { + it.effect( + `reserves waiting siblings when the settled steer's result is ${release === "acknowledge" ? "read" : "disposed"}`, + () => + runScenario(`release-settled-${release}`, (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const { messageId, taskId } = yield* h.reserveSteer(parent.runId); + const siblingIds = yield* h.addPendingSiblings(parent.runId, 2); + yield* h.exhaustSteering(); + yield* h.finishTurn(parent, "completed"); + yield* h.settled(); + + yield* h.orchestrator.dispatch( + release === "acknowledge" + ? { + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make("release-settled"), + parentThreadId: threadId, + taskId, + observedByRunId: parent.runId, + } + : { + type: "delegated_task.completion-delivery.dispose", + commandId: CommandId.make("release-settled"), + parentThreadId: threadId, + taskId, + }, + ); + const released = yield* h.projection(); + const delivery = h.run(released, parent.runId)?.delegatedCompletion?.delivery; + assert.equal(delivery?.generation, 2); + assert.notEqual(delivery?.messageId, messageId); + assert.deepEqual(delivery?.taskIds, siblingIds); + for (const siblingId of siblingIds) + assert.equal(h.task(released, siblingId)?.completionDelivery?.state, "claimed"); + assert.deepEqual( + h.offers.map((offer) => offer.delegatedCompletion), + [ + { parentRunId: parent.runId, generation: 1, messageId }, + { parentRunId: parent.runId, generation: 2, messageId: delivery!.messageId }, + ], + ); + + yield* h.startContinuationWorker(); + const wake = yield* h.awaitDispatchedTurn(2); + assert.equal(wake.message.messageId, delivery?.messageId); + for (const siblingId of siblingIds) assert.include(wake.message.text, String(siblingId)); + assert.notInclude(wake.message.text, String(taskId)); + yield* h.finishTurn(wake, "completed"); + yield* h.settled(); + const delivered = yield* h.projection(); + for (const siblingId of siblingIds) + assert.equal(h.task(delivered, siblingId)?.completionDelivery?.state, "delivered"); + assert.equal(h.run(delivered, parent.runId)?.delegatedCompletion?.delivery, null); + assert.equal(h.dispatches.length, 1); + assert.equal(h.controls.started.length, 2); + }), + ), + ); +} + +it.effect("leaves a completed wake whose result was read to the terminal listener", () => + runScenario("read-completed-wake", (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const { messageId, taskId } = yield* h.reserveSteer(parent.runId); + yield* h.exhaustSteering(); + yield* h.finishTurn(parent, "completed"); + yield* h.settled(); + yield* h.startContinuationWorker(); + const wake = yield* h.awaitDispatchedTurn(2); + const [siblingId] = yield* h.addPendingSiblings(parent.runId, 1); + + const listener = yield* h.holdTerminalListener(); + yield* h.finishTurn(wake, "completed"); + yield* h.orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make("read-completed-wake"), + parentThreadId: threadId, + taskId, + observedByRunId: wake.runId, + }); + const read = yield* h.projection(); + assert.deepEqual( + h.run(read, parent.runId)?.delegatedCompletion?.delivery, + { generation: 1, messageId, taskIds: [] }, + "the completed wake is still settled by the terminal listener", + ); + assert.equal(h.task(read, siblingId!)?.completionDelivery?.state, "pending"); + + yield* Deferred.succeed(listener, undefined); + yield* h.settled(); + const siblingsWake = yield* h.awaitDispatchedTurn(3); + assert.include(siblingsWake.message.text, String(siblingId)); + assert.notInclude(siblingsWake.message.text, String(taskId)); + const cohort = h.run(yield* h.projection(), parent.runId)?.delegatedCompletion; + assert.equal(cohort?.delivery?.generation, 2); + assert.deepEqual(cohort?.delivery?.taskIds, [siblingId!]); + }), + ), +); + +it.effect("keeps an emptied reservation while its steer run is still live", () => + runScenario("read-while-live", (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const { messageId, taskId } = yield* h.reserveSteer(parent.runId); + const [siblingId] = yield* h.addPendingSiblings(parent.runId, 1); + yield* h.exhaustSteering(); + yield* h.completeProviderTurn(parent); + const current = yield* h.projection(); + assert.equal(h.run(current, parent.runId)?.status, "running"); + const providerThreadId = h.run(current, parent.runId)?.providerThreadId; + if (providerThreadId == null) return yield* Effect.die("parent provider thread missing"); + // A startup or wake-policy offer can see the unaccepted steer in this window. + yield* Queue.offer(h.requests, { + threadId, + providerThreadId, + driver, + detail: null, + delivery: "message_text", + delegatedCompletion: { parentRunId: parent.runId, generation: 1, messageId }, + }); + + yield* h.orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make("read-while-live"), + parentThreadId: threadId, + taskId, + observedByRunId: parent.runId, + }); + const read = yield* h.projection(); + assert.deepEqual(h.run(read, parent.runId)?.delegatedCompletion?.delivery, { + generation: 1, + messageId, + taskIds: [], + }); + assert.equal(h.task(read, siblingId!)?.completionDelivery?.state, "pending"); + assert.deepEqual(h.offers, []); + assert.equal( + yield* h.dispatchStaleCompletion(parent.runId, messageId, [taskId]), + "Delegated completion delivery is no longer dispatchable.", + ); + + yield* h.startContinuationWorker(); + yield* h.awaitContinuationReads(1); + assert.deepEqual(h.dispatchAttempts, [], "an emptied reservation starts no wake"); + assert.equal(h.controls.started.length, 1); + }), + ), +); + +it.effect("does not reoffer a steer after Stop disposes its cohort", () => + runScenario("stop", (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const { taskId } = yield* h.reserveSteer(parent.runId); + yield* h.exhaustSteering(); + yield* h.orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("stop"), + threadId, + runId: parent.runId, + }); + yield* h.worker.drain(); + yield* h.finishTurn(parent, "interrupted"); + assert.isAtLeast(yield* h.settled(), 1, "the terminal handler ran"); + assert.deepEqual(h.offers, []); + const stopped = yield* h.projection(); + assert.equal(h.run(stopped, parent.runId)?.delegatedCompletion?.disposition, "stopped"); + assert.equal(h.task(stopped, taskId)?.completionDelivery?.state, "disposed"); + }), + ), +); + +for (const release of ["acknowledge", "dispose"] as const) { + it.effect( + `does not reoffer a reservation whose result was ${release === "acknowledge" ? "read" : "disposed"}`, + () => + runScenario(`released-${release}`, (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const { taskId } = yield* h.reserveSteer(parent.runId); + yield* h.exhaustSteering(); + yield* h.orchestrator.dispatch( + release === "acknowledge" + ? { + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make("release-result"), + parentThreadId: threadId, + taskId, + observedByRunId: parent.runId, + } + : { + type: "delegated_task.completion-delivery.dispose", + commandId: CommandId.make("release-result"), + parentThreadId: threadId, + taskId, + }, + ); + const released = yield* h.projection(); + assert.deepEqual( + h.run(released, parent.runId)?.delegatedCompletion?.delivery?.taskIds, + [], + ); + + yield* h.finishTurn(parent, "completed"); + assert.isAtLeast(yield* h.settled(), 1, "the terminal handler ran"); + assert.equal( + h.run(yield* h.projection(), parent.runId)?.delegatedCompletion?.delivery, + null, + "settlement releases a steer with no unread result", + ); + assert.deepEqual(h.offers, []); + assert.equal(h.controls.started.length, 1); + }), + ), + ); +} + +for (const release of ["acknowledge", "dispose"] as const) { + it.effect( + `delivers a waiting sibling once after a steer ${release === "acknowledge" ? "read" : "disposed"} while live settles`, + () => + runScenario(`live-${release}-then-settle`, (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const { messageId, taskId } = yield* h.reserveSteer(parent.runId); + const [siblingId] = yield* h.addPendingSiblings(parent.runId, 1); + yield* h.exhaustSteering(); + yield* h.resolveResult(release, taskId, parent.runId); + const userMessages = (yield* h.projection()).messages.filter( + (message) => message.createdBy === "user", + ); + assert.deepEqual( + h.run(yield* h.projection(), parent.runId)?.delegatedCompletion?.delivery, + { generation: 1, messageId, taskIds: [] }, + "the live steer keeps its emptied reservation", + ); + + yield* h.finishTurn(parent, "completed"); + yield* h.settled(); + const released = yield* h.projection(); + const delivery = h.run(released, parent.runId)?.delegatedCompletion?.delivery; + assert.equal(delivery?.generation, 2); + assert.notEqual(delivery?.messageId, messageId); + assert.deepEqual(delivery?.taskIds, [siblingId!]); + assert.equal(h.task(released, siblingId!)?.completionDelivery?.state, "claimed"); + assert.deepEqual( + h.offers.map((offer) => offer.delegatedCompletion), + [{ parentRunId: parent.runId, generation: 2, messageId: delivery!.messageId }], + ); + + yield* h.startContinuationWorker(); + const wake = yield* h.awaitDispatchedTurn(2); + assert.equal(wake.message.messageId, delivery?.messageId); + assert.include(wake.message.text, String(siblingId)); + assert.notInclude(wake.message.text, String(taskId)); + yield* h.finishTurn(wake, "completed"); + yield* h.settled(); + const delivered = yield* h.projection(); + assert.equal(h.task(delivered, siblingId!)?.completionDelivery?.state, "delivered"); + assert.equal( + h.task(delivered, taskId)?.completionDelivery?.state, + release === "acknowledge" ? "acknowledged" : "disposed", + ); + const cohort = h.run(delivered, parent.runId)?.delegatedCompletion; + assert.equal(cohort?.delivery, null); + + // A repeated settlement leaves the delivered cohort alone. + yield* h.repeatRunEvent(parent.runId); + yield* h.settled(); + const repeated = yield* h.projection(); + assert.deepEqual(h.run(repeated, parent.runId)?.delegatedCompletion, cohort); + assert.deepEqual(repeated.subagents, delivered.subagents); + assert.equal(h.offers.length, 1); + assert.equal(h.dispatches.length, 1); + assert.equal(h.controls.started.length, 2, "one wake, and no empty wake"); + assert.deepEqual( + repeated.messages.filter((message) => message.createdBy === "user"), + userMessages, + "recovery needs no new user input", + ); + }), + ), + ); +} + +it.effect("keeps a read steer reserved when a rollback lands before its settlement", () => + runScenario("live-read-then-rollback", (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const { messageId, taskId } = yield* h.reserveSteer(parent.runId); + const [siblingId] = yield* h.addPendingSiblings(parent.runId, 1); + yield* h.exhaustSteering(); + yield* h.resolveResult("acknowledge", taskId, parent.runId); + + // The completed event is handled after the rollback is already visible. + const listener = yield* h.holdTerminalListener(); + yield* h.finishTurn(parent, "completed"); + yield* h.rollBack(parent.runId); + yield* Deferred.succeed(listener, undefined); + assert.isAtLeast(yield* h.settled(), 2, "both terminal events were handled"); + const rolledBack = yield* h.projection(); + assert.equal(h.run(rolledBack, parent.runId)?.status, "rolled_back"); + assert.deepEqual(h.run(rolledBack, parent.runId)?.delegatedCompletion?.delivery, { + generation: 1, + messageId, + taskIds: [], + }); + assert.equal(h.task(rolledBack, siblingId!)?.completionDelivery?.state, "pending"); + assert.deepEqual(h.offers, []); + assert.equal(h.controls.started.length, 1); + }), + ), +); + +it.effect("serializes the settlement release with a concurrent sibling read", () => + runScenario("release-under-parent-lock", (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + const { taskId } = yield* h.reserveSteer(parent.runId); + const [siblingId] = yield* h.addPendingSiblings(parent.runId, 1); + yield* h.exhaustSteering(); + yield* h.resolveResult("acknowledge", taskId, parent.runId); + const listener = yield* h.holdTerminalListener(); + yield* h.finishTurn(parent, "completed"); + + // Park the release while it reserves the sibling, then read that sibling. + const reservation = yield* h.holdNextReservation(); + yield* Deferred.succeed(listener, undefined); + const handled = yield* h.settled().pipe(Effect.forkChild); + yield* Effect.raceFirst( + Deferred.await(reservation.entered), + Fiber.join(handled).pipe( + Effect.andThen( + Effect.sync(() => assert.fail("settlement did not reserve the waiting sibling")), + ), + ), + ); + const read = yield* h + .resolveResult("acknowledge", siblingId!, parent.runId) + .pipe(Effect.forkChild); + yield* TestClock.adjust(0); + yield* Deferred.succeed(reservation.release, undefined); + yield* Fiber.join(read); + yield* Fiber.join(handled); + + const after = yield* h.projection(); + assert.equal( + h.task(after, siblingId!)?.completionDelivery?.state, + "acknowledged", + "the read committed after the release, not under a stale claim", + ); + assert.equal(h.run(after, parent.runId)?.delegatedCompletion?.delivery, null); + assert.equal(h.offers.length, 1); + yield* h.startContinuationWorker(); + yield* Queue.offer(h.requests, h.offers[0]!); + yield* h.awaitContinuationReads(2); + assert.deepEqual(h.dispatchAttempts, [], "a reservation read before dispatch starts no wake"); + assert.equal(h.controls.started.length, 1); + }), + ), +); + +for (const removal of ["archive", "delete"] as const) { + it.effect(`does not reoffer an exhausted steer after the thread is ${removal}d`, () => + runScenario(`removed-${removal}`, (h) => + Effect.gen(function* () { + const parent = yield* h.startRun("message:parent"); + yield* h.reserveSteer(parent.runId); + yield* h.exhaustSteering(); + yield* h.orchestrator.dispatch({ + type: removal === "archive" ? "thread.archive" : "thread.delete", + commandId: CommandId.make(`thread-${removal}`), + threadId, + }); + yield* h.worker.drain(); + const removed = yield* h.projection(); + const run = h.run(removed, parent.runId)!; + if (!isTerminalRunStatus(run.status)) { + const now = yield* DateTime.now; + yield* h.sink.write({ + events: [ + { + id: EventId.make(`event:${removal}-terminal`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "completed", completedAt: now }, + }, + ], + }); + } + assert.isAtLeast(yield* h.settled(), 1, "the terminal handler ran"); + assert.deepEqual(h.offers, []); + assert.equal(h.controls.started.length, 1); + }), + ), + ); +} + +const isTerminalRunStatus = (status: OrchestrationV2Run["status"]) => + ["completed", "failed", "interrupted", "cancelled", "rolled_back"].includes(status); + +const runScenario = ( + name: string, + body: (harness: Harness) => Effect.Effect, +) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`completion-settlement-${name}`); + const controls: AdapterControls = { + events: yield* Queue.unbounded(), + started: [], + steered: [], + steer: "reject", + sessionOpens: 0, + interrupts: 0, + }; + const requests = yield* Queue.unbounded(); + const offers: Array = []; + yield* Effect.gen(function* () { + const harness = yield* makeHarness(controls, requests, offers, cwd); + yield* body(harness); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: `completion-settlement-${name}` }, + makeSingleLayer(makeAdapter(cwd, controls)), + { runEffectWorker: false }, + ), + ), + Effect.provideService(ProviderContinuationRequests, { + offer: (request) => + Effect.sync(() => offers.push(request)).pipe( + Effect.andThen(Queue.offer(requests, request)), + ), + take: Queue.take(requests), + }), + ); + }), + ); + +type Harness = Effect.Success>; + +const makeHarness = Effect.fnUntraced(function* ( + controls: AdapterControls, + requests: Queue.Queue, + offers: ReadonlyArray, + cwd: string, +) { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const sink = yield* EventSinkV2; + const store = yield* ProjectionStoreV2; + const dispatches: Array = []; + const dispatchAttempts: Array = []; + const dispatched = yield* Queue.unbounded(); + const continuationReads = yield* Queue.unbounded(); + + // The terminal-run listener handles run updates one at a time, and each + // makes a queue check. A marker run's check on another thread therefore + // proves every earlier terminal event was handled, without adding input to + // this thread. Counting this thread's checks shows its handler ran. + const markerChecks = yield* Queue.unbounded(); + let parentChecks = 0; + let parentChecksAtMarker = 0; + let markerHold: Deferred.Deferred | undefined; + const canStartQueuedRun = store.canStartQueuedRun; + Object.assign(store, { + canStartQueuedRun: (id: ThreadId) => + canStartQueuedRun(id).pipe( + Effect.tap(() => { + if (id === threadId) parentChecks += 1; + if (id !== markerThreadId) return Effect.void; + const hold = markerHold; + return Queue.offer(markerChecks, undefined).pipe( + Effect.andThen(hold === undefined ? Effect.void : Deferred.await(hold)), + ); + }), + ), + }); + // Pauses the next reservation on this thread after it takes the parent lock, + // at the point the rearm lock regression in DelegatedCompletionDelivery uses. + let reservationHold: + | { readonly entered: Deferred.Deferred; readonly release: Deferred.Deferred } + | undefined; + const getMessageCount = store.getMessageCount; + Object.assign(store, { + getMessageCount: (id: ThreadId) => { + const hold = id === threadId ? reservationHold : undefined; + if (hold === undefined) return getMessageCount(id); + reservationHold = undefined; + return Deferred.succeed(hold.entered, undefined).pipe( + Effect.andThen(Deferred.await(hold.release)), + Effect.andThen(getMessageCount(id)), + ); + }, + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => Object.assign(store, { canStartQueuedRun, getMessageCount })), + ); + let markers = 0; + + for (const [id, title] of [ + [threadId, "Completion settlement"], + [markerThreadId, "Terminal listener marker"], + ] as const) { + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create:${id}`), + threadId: id, + projectId: ProjectId.make("project:completion-settlement"), + title, + modelSelection: runningSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + } + + const projection = () => orchestrator.getThreadProjection(threadId); + const run = ( + current: { readonly runs: ReadonlyArray }, + runId: RunId, + ): OrchestrationV2Run | undefined => current.runs.find((candidate) => candidate.id === runId); + const task = (current: Effect.Success>, taskId: NodeId) => + current.subagents.find((candidate) => candidate.id === taskId); + + const watch = Effect.fnUntraced(function* ( + predicate: (event: OrchestrationV2DomainEvent) => boolean, + ) { + const afterSequence = yield* sink.latestSequence(); + return yield* sink.stream({ afterSequence }).pipe( + Stream.map((stored) => stored.event), + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + }); + + const settled = Effect.fnUntraced(function* () { + markers += 1; + const current = yield* orchestrator.getThreadProjection(markerThreadId); + const now = yield* DateTime.now; + const template = current.runs[0] ?? (yield* projection()).runs[0]!; + const { delegatedCompletion: _cohort, ...base } = template; + yield* sink.write({ + events: [ + { + id: EventId.make(`event:marker:${markers}`), + type: "run.updated", + threadId: markerThreadId, + runId: markerRunId, + occurredAt: now, + payload: { + ...base, + id: markerRunId, + threadId: markerThreadId, + ordinal: 1, + status: "completed", + completedAt: now, + }, + }, + ], + }); + yield* Queue.take(markerChecks); + const handled = parentChecks - parentChecksAtMarker; + parentChecksAtMarker = parentChecks; + return handled; + }); + + // Parks the listener on a marker so a command can take the parent lock + // before a later terminal event is handled. Complete the result to release it. + const holdTerminalListener = Effect.fnUntraced(function* () { + const hold = yield* Deferred.make(); + markerHold = hold; + yield* settled(); + markerHold = undefined; + return hold; + }); + + const holdNextReservation = Effect.fnUntraced(function* () { + const hold = { entered: yield* Deferred.make(), release: yield* Deferred.make() }; + reservationHold = hold; + return hold; + }); + + // Acknowledges (reads) or disposes one delegated result. + const resolveResult = ( + release: "acknowledge" | "dispose", + taskId: NodeId, + observedByRunId: RunId, + ) => + orchestrator.dispatch( + release === "acknowledge" + ? { + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make(`acknowledge:${taskId}`), + parentThreadId: threadId, + taskId, + observedByRunId, + } + : { + type: "delegated_task.completion-delivery.dispose", + commandId: CommandId.make(`dispose:${taskId}`), + parentThreadId: threadId, + taskId, + }, + ); + + const startRun = Effect.fnUntraced(function* (id: string) { + const running = yield* watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`dispatch:${id}`), + threadId, + messageId: MessageId.make(id), + text: id, + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(running); + return controls.started.at(-1)!; + }); + + const finishTurn = Effect.fnUntraced(function* ( + turnInput: ProviderAdapterV2TurnInput, + status: "completed" | "interrupted", + ) { + const current = yield* orchestrator.getThreadProjection(turnInput.threadId); + const providerTurn = current.providerTurns.find( + (candidate) => candidate.runAttemptId === turnInput.attemptId, + )!; + const reached = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === turnInput.runId && + event.payload.status === (status === "completed" ? "waiting" : status), + ); + yield* Queue.offer(controls.events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...providerTurn, status, completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(controls.events, { + type: "turn.terminal", + driver, + providerThreadId: providerTurn.providerThreadId, + providerTurnId: providerTurn.id, + runOrdinal: turnInput.runOrdinal, + status, + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(reached); + yield* worker.drain(); + assert.equal( + run(yield* orchestrator.getThreadProjection(turnInput.threadId), turnInput.runId)?.status, + status, + ); + }); + + // The provider reports the turn complete before its terminal event arrives, + // so the run is still live while its unaccepted steer is already undelivered. + const completeProviderTurn = Effect.fnUntraced(function* (turnInput: ProviderAdapterV2TurnInput) { + const providerTurn = (yield* projection()).providerTurns.find( + (candidate) => candidate.runAttemptId === turnInput.attemptId, + )!; + const reached = yield* watch( + (event) => + event.type === "provider-turn.updated" && + event.payload.id === providerTurn.id && + event.payload.status === "completed", + ); + yield* Queue.offer(controls.events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...providerTurn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Fiber.join(reached); + yield* worker.drain(); + }); + + const saveDesiredSelection = Effect.fnUntraced(function* (runId: RunId) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("desired-selection"), + threadId, + messageId: desiredSelectionMessageId, + text: "use the new model next turn", + attachments: [], + modelSelection: desiredSelection, + dispatchMode: { type: "steer_active", targetRunId: runId }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + const selected = yield* projection(); + assert.deepEqual(selected.thread.modelSelection, desiredSelection); + assert.deepEqual(run(selected, runId)?.modelSelection, runningSelection); + }); + + // Seeds the claimed first result of an open cohort, then routes its durable + // mailbox message through the orchestrator, which steers the active run. + const reserveSteer = Effect.fnUntraced(function* (ownerRunId: RunId) { + const messageId = MessageId.make("message:completion-steer"); + const taskId = NodeId.make("task:first"); + const current = yield* projection(); + const owner = run(current, ownerRunId)!; + const ownerTurn = controls.started.find((turn) => turn.runId === ownerRunId)!; + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make("event:cohort"), + type: "run.updated", + threadId, + runId: ownerRunId, + occurredAt: now, + payload: { + ...owner, + delegatedCompletion: { + disposition: "open", + nextGeneration: 2, + delivery: { generation: 1, messageId, taskIds: [taskId] }, + }, + }, + }, + { + id: EventId.make("event:first-task"), + type: "subagent.updated", + threadId, + runId: ownerRunId, + nodeId: taskId, + occurredAt: now, + payload: { + id: taskId, + threadId, + runId: ownerRunId, + parentNodeId: ownerTurn.rootNodeId, + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId: instanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Do background work", + title: "First result", + model: null, + completionWake: "always", + completionDelivery: { state: "claimed", observedByRunId: null }, + status: "completed", + result: "done", + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }, + ], + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("completion-delivery"), + threadId, + messageId, + text: "Delegated task completed.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + delegatedCompletion: { parentRunId: ownerRunId, generation: 1, taskIds: [taskId] }, + }); + return { messageId, taskId }; + }); + + // Siblings that finished while the first delivery was in flight wait as pending. + const addPendingSiblings = Effect.fnUntraced(function* (ownerRunId: RunId, count: number) { + const first = (yield* projection()).subagents.find( + (candidate) => candidate.runId === ownerRunId, + )!; + const now = yield* DateTime.now; + const ids = Array.from({ length: count }, (_, index) => + NodeId.make(`task:sibling-${index + 1}`), + ); + yield* sink.write({ + events: ids.map((id) => ({ + id: EventId.make(`event:${id}`), + type: "subagent.updated" as const, + threadId, + runId: ownerRunId, + nodeId: id, + occurredAt: now, + payload: { + ...first, + id, + title: `Sibling ${id}`, + completionDelivery: { state: "pending" as const, observedByRunId: null }, + }, + })), + }); + return ids; + }); + + // Requests a real app-owned child of the parent run and returns its running turn. + const requestChild = Effect.fnUntraced(function* (parent: ProviderAdapterV2TurnInput) { + const requested = yield* orchestrator.dispatch({ + type: "delegated_task.request", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make("request-later-sibling"), + parentThreadId: threadId, + parentRunId: parent.runId, + parentNodeId: parent.rootNodeId, + task: "Finish after the first result was read", + title: "Later sibling", + modelSelection: runningSelection, + runtimeMode: "full-access", + interactionMode: "default", + completionWake: "always", + }); + yield* worker.drain(); + const child = requested.storedEvents + .map((stored) => stored.event) + .find( + (event): event is Extract => + event.type === "subagent.updated" && event.payload.origin === "app_owned", + )!.payload; + const turn = controls.started.find((started) => started.threadId === child.childThreadId)!; + assert.isDefined(turn, "the child turn started"); + return { taskId: child.id, turn }; + }); + + // Drives every real outbox retry, moving virtual time only to scheduled boundaries. + const exhaustSteering = Effect.fnUntraced(function* () { + for (let attempt = 0; attempt < 5; attempt += 1) { + yield* worker.drain(); + const next = yield* worker.nextClaimableAt; + if (Option.isSome(next)) yield* TestClock.setTime(DateTime.toEpochMillis(next.value)); + } + assert.isTrue(Option.isNone(yield* worker.nextClaimableAt)); + }); + + const repeatRunEvent = Effect.fnUntraced(function* (runId: RunId) { + const current = run(yield* projection(), runId)!; + yield* sink.write({ + events: [ + { + id: EventId.make(`event:repeat:${runId}:${yield* sink.latestSequence()}`), + type: "run.updated", + threadId, + runId, + occurredAt: yield* DateTime.now, + payload: current, + }, + ], + }); + }); + + // CheckpointRollbackService spreads the discarded run and keeps its cohort. + const rollBack = Effect.fnUntraced(function* (runId: RunId) { + const current = run(yield* projection(), runId)!; + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make(`event:rollback:${runId}`), + type: "run.updated", + threadId, + runId, + occurredAt: now, + payload: { ...current, status: "rolled_back", completedAt: now }, + }, + ], + }); + }); + + // Sends what the continuation worker builds from a read taken before the + // reservation changed, and returns why the orchestrator refused it. + const dispatchStaleCompletion = ( + parentRunId: RunId, + messageId: MessageId, + taskIds: ReadonlyArray, + ) => + orchestrator + .dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`stale-completion:${messageId}`), + threadId, + messageId, + text: "Delegated task completed.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + delegatedCompletion: { parentRunId, generation: 1, taskIds }, + }) + .pipe( + Effect.as("dispatched"), + Effect.catchTag("OrchestratorDispatchError", (error) => + Effect.succeed(String(error.cause)), + ), + ); + + const startContinuationWorker = () => + Layer.build( + continuationWorker.pipe( + Layer.provide(idAllocatorLayer), + Layer.provide( + Layer.mock(ThreadManagementService)({ + getThreadRecords: (id, fields, filter) => + orchestrator + .getThreadRecords(id, fields, filter) + .pipe(Effect.tap(() => Queue.offer(continuationReads, undefined))), + dispatch: (command) => + Effect.sync(() => dispatchAttempts.push(command.commandId)).pipe( + Effect.andThen(orchestrator.dispatch(command)), + Effect.tap(() => + Effect.sync(() => dispatches.push(command.commandId)).pipe( + Effect.andThen(Queue.offer(dispatched, undefined)), + ), + ), + ), + }), + ), + ), + ); + + // Waits for the next continuation dispatch to start provider turn `count`. + // An earlier drain may already have started it, so check before joining. + const awaitDispatchedTurn = Effect.fnUntraced(function* (count: number) { + yield* Queue.take(dispatched); + const isRunning = (event: OrchestrationV2DomainEvent) => + event.type === "provider-turn.updated" && + event.payload.status === "running" && + event.payload.runAttemptId === controls.started[count - 1]?.attemptId; + const running = yield* watch(isRunning); + yield* worker.drain(); + const turn = controls.started[count - 1]; + const alreadyRunning = + turn !== undefined && + (yield* projection()).providerTurns.some( + (candidate) => candidate.runAttemptId === turn.attemptId && candidate.status === "running", + ); + yield* alreadyRunning ? Fiber.interrupt(running) : Fiber.join(running); + assert.equal(controls.started.length, count); + return controls.started[count - 1]!; + }); + + const awaitContinuationReads = Effect.fnUntraced(function* (count: number) { + for (let read = 0; read < count; read += 1) yield* Queue.take(continuationReads); + }); + + return { + controls, + requests, + offers, + dispatches, + dispatchAttempts, + orchestrator, + worker, + sink, + projection, + run, + task, + settled, + holdTerminalListener, + holdNextReservation, + resolveResult, + startRun, + finishTurn, + completeProviderTurn, + saveDesiredSelection, + reserveSteer, + requestChild, + addPendingSiblings, + exhaustSteering, + repeatRunEvent, + rollBack, + dispatchStaleCompletion, + startContinuationWorker, + awaitDispatchedTurn, + awaitContinuationReads, + }; +}); + +function makeAdapter(cwd: string, controls: AdapterControls): ProviderAdapterV2Shape { + return { + instanceId, + driver, + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: (input) => + Effect.gen(function* () { + controls.sessionOpens += 1; + const now = yield* DateTime.now; + return { + instanceId, + driver, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: runningSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(controls.events), + ensureThread: ({ threadId: appThreadId }) => + Effect.succeed({ + id: ProviderThreadId.make(`provider-thread:${appThreadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => + Effect.gen(function* () { + controls.started.push(turn); + yield* Queue.offer(controls.events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: { + driver, + nativeId: `native:${turn.attemptId}`, + strength: "strong", + }, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: yield* DateTime.now, + completedAt: null, + }, + }); + }), + steerTurn: (turn) => + Effect.gen(function* () { + if (turn.message.messageId === desiredSelectionMessageId) return; + controls.steered.push(turn.message.messageId); + if (controls.steer === "accept") return; + return yield* new ProviderAdapterSteerRunError({ + driver, + providerThreadId: turn.providerThread.id, + providerTurnId: turn.providerTurnId, + cause: "Pi turn is not active.", + }); + }), + interruptTurn: () => + Effect.sync(() => { + controls.interrupts += 1; + }), + respondToRuntimeRequest: () => Effect.void, + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }; +} diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index fcaa2d6d1c16..caf067a67d3b 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1050,13 +1050,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); const reservePendingDelegatedCompletionDelivery = Effect.fnUntraced(function* ( - projection: Pick< - OrchestrationV2ThreadProjection, - "thread" | "runs" | "subagents" | "providerTurns" - >, + projection: Pick, parentRun: OrchestrationV2Run, - emitEvent: ReturnType, - command: OrchestrationV2Command, + emitEvent: ( + event: Omit, + ) => Effect.Effect, + command?: OrchestrationV2Command, ) { const threadId = projection.thread.id; const cohort = parentRun.delegatedCompletion; @@ -1140,6 +1139,75 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } }); + // A provider can reject every steering retry before its terminal event + // reaches the projection, so the run still looked active to the outbox. + // Once it settles, reoffer the reservations it still carries as unaccepted + // steers. Fresh reservations and pending-only cohorts keep their own owners. + // A steer whose results were all read while its run was live can no longer + // deliver, so release it and reserve the siblings waiting behind it, unless + // its owner was rolled back. + const recoverSettledMailboxSteers = (threadId: ThreadId, settledRun: OrchestrationV2Run) => + Effect.gen(function* () { + const { messages } = yield* projectionStore.getThreadRecords(threadId, ["messages"], { + messageRunIds: [settledRun.id], + messageRoles: ["user"], + }); + const steers = messages.filter( + (message) => + message.delegatedCompletion !== undefined && message.id !== settledRun.userMessageId, + ); + if (steers.length === 0) return; + const { runs } = yield* projectionStore.getThreadRecords(threadId, ["runs"]); + for (const steer of steers) { + const parentRunId = steer.delegatedCompletion!.parentRunId; + const parentRun = runs.find((run) => run.id === parentRunId); + const delivery = parentRun?.delegatedCompletion?.delivery; + if (parentRun === undefined || delivery?.messageId !== steer.id) continue; + if (delivery.taskIds.length > 0) { + yield* offerDelegatedCompletionDelivery(threadId, parentRunId); + } else if (parentRun.status !== "rolled_back") { + yield* releaseSettledMailboxSteer(threadId, runs, parentRun); + } + } + }); + + const releaseSettledMailboxSteer = ( + threadId: ThreadId, + runs: ReadonlyArray, + parentRun: OrchestrationV2Run, + ) => + Effect.gen(function* () { + const cohort = parentRun.delegatedCompletion; + if (cohort === undefined) return; + const { thread, subagents } = yield* projectionStore.getThreadRecords(threadId, [ + "subagents", + ]); + const released: OrchestrationV2Run = { + ...parentRun, + delegatedCompletion: { ...cohort, delivery: null }, + }; + const events: Array> = [ + { + type: "run.updated", + threadId, + runId: released.id, + ...(released.rootNodeId === null ? {} : { nodeId: released.rootNodeId }), + providerInstanceId: released.providerInstanceId, + occurredAt: yield* DateTime.now, + payload: released, + }, + ]; + yield* reservePendingDelegatedCompletionDelivery( + { thread, runs, subagents }, + released, + (event) => Effect.sync(() => events.push(event)), + ); + yield* writeSystemEvents(events); + if (events.length > 1) { + yield* offerDelegatedCompletionDelivery(threadId, released.id); + } + }); + const emitQueuedRunCancellation = (input: { readonly command: OrchestrationV2Command; readonly events: Ref.Ref>; @@ -2116,9 +2184,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const remainingTaskIds = delivery.taskIds.filter((taskId) => taskId !== task.id); const deliveryRun = completionDeliveryRun(projection, delivery); + // A steer left on a settled run is never accepted, and recovery will not + // offer an empty reservation, so keeping it would strand later siblings. + const releaseSettledSteer = + deliveryRun !== undefined && + deliveryRun.userMessageId !== delivery.messageId && + !isBlockingRun(deliveryRun) && + parentRun.status !== "rolled_back"; const clearDelivery = remainingTaskIds.length === 0 && - (deliveryRun === undefined || deliveryRun.status === "queued"); + (deliveryRun === undefined || deliveryRun.status === "queued" || releaseSettledSteer); const updatedCohort: OrchestrationV2DelegatedCompletionCohort = { ...cohort, delivery: clearDelivery @@ -2140,6 +2215,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio delegatedCompletion: updatedCohort, }, }); + if (clearDelivery && releaseSettledSteer) { + yield* reservePendingDelegatedCompletionDelivery( + projection, + { ...parentRun, delegatedCompletion: updatedCohort }, + emitEvent, + command, + ); + return; + } if (deliveryRun?.status === "queued") { if (remainingTaskIds.length === 0) { @@ -4614,8 +4698,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const delivery = parentRun?.delegatedCompletion?.delivery; if ( parentRun?.delegatedCompletion?.disposition !== "open" || + parentRun.status === "rolled_back" || delivery === null || delivery === undefined || + delivery.taskIds.length === 0 || delivery.generation !== requestedCompletion.generation || delivery.messageId !== command.messageId || (projection.messages.some((candidate) => candidate.id === command.messageId) && @@ -9637,6 +9723,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (command.type === "delegated_task.wake-policy") { yield* mapDispatchError(command)(offerDelegatedCompletionDeliveries(command.parentThreadId)); } + if ( + (command.type === "delegated_task.completion-delivery.acknowledge" || + command.type === "delegated_task.completion-delivery.dispose") && + committed.storedEvents.some( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.completionDelivery?.state === "claimed", + ) + ) { + yield* mapDispatchError(command)(offerDelegatedCompletionDeliveries(command.parentThreadId)); + } return { sequence: committed.receipt.resultSequence, @@ -9675,6 +9772,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : undefined, ), ); + // Recovery offers asynchronously, so it follows queue promotion rather + // than delaying it. Rollback discards the run and its unaccepted steers. + if (stored.event.type === "run.updated" && stored.event.payload.status !== "rolled_back") { + yield* threadDispatch.withLock( + threadId, + recoverSettledMailboxSteers(threadId, stored.event.payload), + ); + } }).pipe( Effect.catchCause((cause) => Effect.logWarning("Failed to react to terminal V2 run", { diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.ts index 3384e6a6dff9..f1d16b0c41ca 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts @@ -28,10 +28,14 @@ function currentDelegatedCompletionDelivery( const alreadyDispatched = projection.messages.some( (message) => message.id === completion.messageId, ); + // A queued offer can outlive its reservation: reading every result empties + // it, and rolling back the owner run discards what it would deliver. if ( sourceRun?.delegatedCompletion?.disposition !== "open" || + sourceRun.status === "rolled_back" || delivery === null || delivery === undefined || + delivery.taskIds.length === 0 || delivery.generation !== completion.generation || delivery.messageId !== completion.messageId || (alreadyDispatched && !isUndeliveredMailboxSteer(projection, completion.messageId)) From 373ebe545077bd4ca2310a4238c149b7dcb1e3af Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Fri, 25 Sep 2026 12:29:35 -0400 Subject: [PATCH 20/34] fix(orchestrator): Batch compatible queued Codex command completions --- .../DelegatedCompletionDelivery.test.ts | 604 ++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 84 +++ ...exBackgroundCoalescing.integration.test.ts | 212 ++++++ .../codex_transcript.ndjson | 25 + 4 files changed, 925 insertions(+) create mode 100644 apps/server/src/orchestration-v2/testkit/CodexBackgroundCoalescing.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/codex_background_command_coalescing/codex_transcript.ndjson diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index c5b270f4bcde..5799fb2bfee3 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -473,6 +473,610 @@ const seedParentWithTerminalTask = (input: { }); it.layer(TestLayer)("delegated completion delivery repairs", (it) => { + const queueCommandCompletion = (input: { + readonly threadId: ThreadId; + readonly label: string; + readonly outcome?: "completed" | "failed" | "cancelled" | "updated" | "unknown"; + readonly senderThreadId?: ThreadId; + readonly selection?: ModelSelection; + }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + // Codex offers command completions as message_text wakes, which the + // continuation worker dispatches as server-created queued notifications. + return yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`command:coalesce:${input.threadId}:${input.label}`), + threadId: input.threadId, + messageId: MessageId.make(`message:coalesce:${input.threadId}:${input.label}`), + text: `Background command completed: ${input.label}\nOutput tail: ${input.label}_OUTPUT`, + notification: { + source: { kind: "command" }, + outcome: input.outcome ?? "completed", + summary: "Background command finished", + detail: input.label, + }, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + modelSelection: input.selection ?? modelSelection, + ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + }); + }); + const completeParentAndAwaitStart = (input: { + readonly threadId: ThreadId; + readonly parentRunId: RunId; + readonly startingRunId: RunId; + readonly now: DateTime.Utc; + }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const projection = yield* orchestrator.getThreadProjection(input.threadId); + const parent = projection.runs.find((run) => run.id === input.parentRunId)!; + const afterSequence = yield* sink.latestSequence(); + yield* sink.write({ + events: [ + { + id: EventId.make(`${input.threadId}:parent-complete`), + type: "run.updated", + threadId: input.threadId, + runId: parent.id, + occurredAt: input.now, + payload: { ...parent, status: "completed", completedAt: input.now }, + }, + ], + }); + yield* sink.stream({ afterSequence, eventType: "run.updated" }).pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + event.payload.id === input.startingRunId && + event.payload.status === "starting", + ), + Stream.take(1), + Stream.runDrain, + ); + }); + const seedActiveCodexParent = (label: string, now: DateTime.Utc) => + Effect.gen(function* () { + const threadId = ThreadId.make(`thread:codex-coalescing:${label}`); + const parentRunId = RunId.make(`${threadId}:run`); + yield* seedParentWithTerminalTask({ + threadId, + now, + runId: parentRunId, + projectId: ProjectId.make(`${threadId}:project`), + rootNodeId: NodeId.make(`${threadId}:root`), + taskId: NodeId.make(`${threadId}:task`), + deliveryState: "delivered", + }); + return { threadId, parentRunId }; + }); + + for (const [firstOutcome, secondOutcome, expected] of [ + ["cancelled", "completed", "cancelled"], + ["completed", "cancelled", "cancelled"], + ["failed", "cancelled", "failed"], + ["cancelled", "failed", "failed"], + ["updated", "cancelled", "cancelled"], + ["cancelled", "updated", "cancelled"], + ["completed", "completed", "completed"], + ["completed", "updated", "updated"], + ["completed", "unknown", "unknown"], + ["unknown", "updated", "unknown"], + ["unknown", "unknown", "unknown"], + ] as const) { + it.effect(`persists queued batch outcome ${firstOutcome} then ${secondOutcome}`, () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const now = yield* DateTime.now; + const { threadId, parentRunId } = yield* seedActiveCodexParent( + `outcome:${firstOutcome}:${secondOutcome}`, + now, + ); + yield* queueCommandCompletion({ threadId, label: "1", outcome: firstOutcome }); + const before = yield* orchestrator.getThreadProjection(threadId); + const original = before.messages.find((row) => row.notification !== undefined)!; + assert.isEmpty(before.turnItems.filter((item) => item.runId === original.runId)); + yield* queueCommandCompletion({ threadId, label: "2", outcome: secondOutcome }); + const after = yield* orchestrator.getThreadProjection(threadId); + const notifications = after.messages.filter((row) => row.notification !== undefined); + assert.lengthOf(notifications, 1); + const message = notifications[0]!; + assert.equal(message.notification?.outcome, expected); + assert.equal(message.notification?.summary, "Background commands finished"); + assert.equal(message.notification?.detail, "1\n\n2"); + assert.equal( + message.text, + "Background command completed: 1\nOutput tail: 1_OUTPUT\n\n" + + "Background command completed: 2\nOutput tail: 2_OUTPUT", + ); + // The batch keeps the first delivery's identity and provenance. + assert.equal(message.id, original.id); + assert.equal(message.runId, original.runId); + assert.equal(message.nodeId, original.nodeId); + assert.equal(message.createdBy, "agent"); + assert.equal(message.creationSource, "server"); + assert.deepEqual(message.createdAt, original.createdAt); + assert.lengthOf( + after.runs.filter((run) => run.status === "queued"), + 1, + ); + assert.equal(after.runs.find((run) => run.id === message.runId)?.status, "queued"); + // Queued notifications enter the timeline only when promoted. + assert.isEmpty(after.turnItems.filter((item) => item.runId === message.runId)); + yield* completeParentAndAwaitStart({ + threadId, + parentRunId, + startingRunId: message.runId!, + now, + }); + const promoted = yield* orchestrator.getThreadProjection(threadId); + const items = promoted.turnItems + .filter((item) => item.type === "notification") + .filter((item) => item.runId === message.runId); + assert.lengthOf(items, 1); + assert.equal(items[0]?.outcome, expected); + assert.equal(items[0]?.detail, "1\n\n2"); + }), + ); + } + + it.effect( + "batches compatible command notifications without changing sender or promotion ownership", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const { threadId, parentRunId } = yield* seedActiveCodexParent("compatibility", now); + const send = ( + label: string, + options: { readonly senderThreadId?: ThreadId; readonly selection?: ModelSelection } = {}, + ) => queueCommandCompletion({ threadId, label, ...options }); + const messageIdFor = (label: string) => + MessageId.make(`message:coalesce:${threadId}:${label}`); + yield* send("A"); + for (const kind of ["user", "peer", "native-task"] as const) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`command:coalesce:${threadId}:${kind}`), + threadId, + messageId: messageIdFor(kind), + text: kind, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: kind === "user" ? "user" : "agent", + creationSource: kind === "user" ? "web" : kind === "peer" ? "provider" : "server", + ...(kind === "native-task" + ? { + notification: { + source: { kind: "background_task" as const }, + outcome: "completed" as const, + summary: "Background task finished", + }, + } + : {}), + }); + } + const sender = ThreadId.make("command:sender"); + yield* send("SENDER", { senderThreadId: sender }); + yield* send("SENDER2", { senderThreadId: sender }); + yield* send("MODEL", { selection: { ...modelSelection, model: "other-model" } }); + yield* send("B"); + yield* send("B"); + const queued = yield* orchestrator.getThreadProjection(threadId); + const first = queued.messages.find((message) => message.id === messageIdFor("A"))!; + assert.include(first.text, "A_OUTPUT"); + assert.include(first.text, "B_OUTPUT"); + // A replayed command id is answered from its receipt, not merged again. + assert.equal(first.text.split("B_OUTPUT").length, 2); + assert.equal(first.notification?.detail, "A\n\nB"); + assert.isUndefined(first.senderThreadId); + assert.notInclude(first.text, "SENDER_OUTPUT"); + assert.notInclude(first.text, "MODEL_OUTPUT"); + const senderMessage = queued.messages.find( + (message) => message.id === messageIdFor("SENDER"), + )!; + assert.equal(senderMessage.senderThreadId, sender); + assert.include(senderMessage.text, "SENDER2_OUTPUT"); + assert.equal(senderMessage.notification?.detail, "SENDER\n\nSENDER2"); + const modelMessage = queued.messages.find( + (message) => message.id === messageIdFor("MODEL"), + )!; + assert.equal( + queued.runs.find((run) => run.userMessageId === modelMessage.id)?.modelSelection.model, + "other-model", + ); + assert.lengthOf( + queued.runs.filter((run) => run.status === "queued"), + 6, + ); + assert.isFalse(queued.messages.some((message) => message.id === messageIdFor("B"))); + assert.isFalse(queued.messages.some((message) => message.id === messageIdFor("SENDER2"))); + for (const kind of ["user", "peer", "native-task"]) { + assert.equal( + queued.messages.find((message) => message.id === messageIdFor(kind))?.text, + kind, + ); + } + // Each constituent completion keeps its own accepted receipt, and both + // receipts name the batch message rather than a discarded message id. + for (const label of ["A", "B"]) { + const stored = yield* sink + .readByCommandId({ commandId: CommandId.make(`command:coalesce:${threadId}:${label}`) }) + .pipe(Stream.runCollect); + const messages = Array.from(stored).filter( + ({ event }) => event.type === "message.updated", + ); + assert.lengthOf(messages, 1, label); + const event = messages[0]!.event; + assert.equal(event.type === "message.updated" ? event.payload.id : null, first.id); + } + const firstRun = queued.runs.find((run) => run.userMessageId === first.id)!; + const edit = yield* Effect.result( + orchestrator.dispatch({ + type: "queued-run.edit", + commandId: CommandId.make(`command:coalesce:${threadId}:public-edit`), + threadId, + runId: firstRun.id, + text: "must not replace outputs", + }), + ); + assert.equal(edit._tag, "Failure"); + yield* completeParentAndAwaitStart({ + threadId, + parentRunId, + startingRunId: firstRun.id, + now, + }); + yield* send("C"); + const promoted = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + promoted.messages.find((message) => message.id === first.id)?.text, + first.text, + ); + assert.isTrue( + promoted.runs.some( + (run) => run.status === "queued" && run.userMessageId === messageIdFor("C"), + ), + ); + const activity = promoted.turnItems.find( + (item) => item.runId === firstRun.id && item.type === "notification", + ); + assert.equal(activity?.type, "notification"); + if (activity?.type !== "notification") return; + assert.equal(activity.summary, "Background commands finished"); + assert.equal(activity.detail, "A\n\nB"); + }), + ); + + it.effect("keeps command batches apart from other provider threads and legacy wakes", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const { threadId } = yield* seedActiveCodexParent("foreign", now); + const seedQueuedCompletion = (input: { + readonly label: string; + readonly ordinal: number; + readonly providerThreadId: ProviderThreadId; + readonly creationSource: "server" | "provider"; + }) => { + const runId = RunId.make(`${threadId}:run:${input.label}`); + const messageId = MessageId.make(`message:coalesce:${threadId}:${input.label}`); + return sink.write({ + events: [ + { + id: EventId.make(`event:coalesce:${threadId}:${input.label}:message`), + type: "message.updated", + threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: messageId, + threadId, + runId, + nodeId: null, + role: "user", + text: input.label, + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: input.creationSource, + notification: { + source: { kind: "command" }, + outcome: "completed", + summary: "Background command finished", + detail: input.label, + }, + createdAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make(`event:coalesce:${threadId}:${input.label}:run`), + type: "run.updated", + threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: input.ordinal, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: input.providerThreadId, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: null, + status: "queued", + queuePosition: input.ordinal, + requestedAt: now, + startedAt: null, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + }, + ], + }); + }; + const ownProviderThread = ProviderThreadId.make( + `provider-thread:${String(threadId).replace("thread:", "")}`, + ); + // A completion queued for another provider thread of the same instance. + yield* seedQueuedCompletion({ + label: "FOREIGN", + ordinal: 2, + providerThreadId: ProviderThreadId.make(`${threadId}:other-provider-thread`), + creationSource: "server", + }); + // An adapter-buffered wake persisted before Codex used message_text delivery. + yield* seedQueuedCompletion({ + label: "LEGACY", + ordinal: 3, + providerThreadId: ownProviderThread, + creationSource: "provider", + }); + yield* queueCommandCompletion({ threadId, label: "A" }); + const projection = yield* orchestrator.getThreadProjection(threadId); + for (const label of ["FOREIGN", "LEGACY"]) { + const message = projection.messages.find( + (row) => row.id === `message:coalesce:${threadId}:${label}`, + ); + assert.equal(message?.text, label); + assert.equal(message?.notification?.detail, label); + } + const own = projection.messages.find((row) => row.id === `message:coalesce:${threadId}:A`)!; + assert.equal(own.notification?.detail, "A"); + assert.equal( + projection.runs.find((run) => run.userMessageId === own.id)?.providerThreadId, + ownProviderThread, + ); + assert.lengthOf( + projection.runs.filter((run) => run.status === "queued"), + 3, + ); + }), + ); + + it.effect("keeps command batches apart from delegated deliveries and cancelled batches", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:codex-coalescing:delegated"); + const parentRunId = RunId.make(`${threadId}:run`); + const taskId = NodeId.make(`${threadId}:task`); + const deliveryMessageId = MessageId.make(`message:delegated-delivery:${threadId}`); + yield* seedParentWithTerminalTask({ + threadId, + now, + runId: parentRunId, + projectId: ProjectId.make(`${threadId}:project`), + rootNodeId: NodeId.make(`${threadId}:root`), + taskId, + deliveryState: "claimed", + deliveryTaskIds: [taskId], + }); + // The continuation worker's reserved delivery wake is itself a + // server-created queued message; it must never absorb a command batch. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`command:delegated-delivery:${threadId}`), + threadId, + messageId: deliveryMessageId, + text: `Delegated task ${taskId} reached a terminal state.`, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + delegatedCompletion: { parentRunId, generation: 1, taskIds: [taskId] }, + }); + const reserved = (yield* orchestrator.getThreadProjection(threadId)).messages.find( + (message) => message.id === deliveryMessageId, + )!; + yield* queueCommandCompletion({ threadId, label: "A" }); + yield* queueCommandCompletion({ threadId, label: "B" }); + const queued = yield* orchestrator.getThreadProjection(threadId); + const delivery = queued.messages.find((message) => message.id === deliveryMessageId)!; + assert.deepEqual(delivery, reserved); + assert.deepEqual(delivery.delegatedCompletion, { + parentRunId, + generation: 1, + taskIds: [taskId], + }); + assert.isUndefined(delivery.notification); + const batch = queued.messages.find( + (message) => message.id === `message:coalesce:${threadId}:A`, + )!; + assert.equal(batch.notification?.detail, "A\n\nB"); + assert.deepEqual( + queued.runs.find((run) => run.id === parentRunId)?.delegatedCompletion?.delivery, + { generation: 1, messageId: deliveryMessageId, taskIds: [taskId] }, + ); + assert.equal( + queued.subagents.find((task) => task.id === taskId)?.completionDelivery?.state, + "claimed", + ); + + // Cancelling the batch settles only that run; a later completion starts + // a new batch and does not revive the cancelled outputs. + const batchRun = queued.runs.find((run) => run.userMessageId === batch.id)!; + yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make(`command:coalesce:${threadId}:cancel`), + threadId, + runId: batchRun.id, + }); + yield* queueCommandCompletion({ threadId, label: "C", outcome: "failed" }); + const afterCancel = yield* orchestrator.getThreadProjection(threadId); + assert.equal(afterCancel.runs.find((run) => run.id === batchRun.id)?.status, "cancelled"); + assert.equal( + afterCancel.messages.find((message) => message.id === batch.id)?.text, + batch.text, + ); + const later = afterCancel.messages.find( + (message) => message.id === `message:coalesce:${threadId}:C`, + )!; + assert.equal(later.text, "Background command completed: C\nOutput tail: C_OUTPUT"); + assert.equal(later.notification?.outcome, "failed"); + assert.equal( + afterCancel.runs.find((run) => run.userMessageId === later.id)?.status, + "queued", + ); + assert.deepEqual( + afterCancel.runs.find((run) => run.id === parentRunId)?.delegatedCompletion?.delivery, + { generation: 1, messageId: deliveryMessageId, taskIds: [taskId] }, + ); + assert.equal( + afterCancel.messages.find((message) => message.id === deliveryMessageId)?.text, + delivery.text, + ); + + // Disposing the delegated delivery leaves the command batch intact. + const deliveryRun = afterCancel.runs.find((run) => run.userMessageId === deliveryMessageId)!; + yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make(`command:delegated-delivery:${threadId}:cancel`), + threadId, + runId: deliveryRun.id, + }); + yield* queueCommandCompletion({ threadId, label: "D" }); + const afterDispose = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterDispose.runs.find((run) => run.id === parentRunId)?.delegatedCompletion?.disposition, + "disposed", + ); + const survivor = afterDispose.messages.find((message) => message.id === later.id)!; + assert.equal(survivor.notification?.detail, "C\n\nD"); + assert.equal(survivor.notification?.outcome, "failed"); + assert.equal( + afterDispose.runs.find((run) => run.userMessageId === later.id)?.status, + "queued", + ); + }), + ); + + it.effect("batches across a restart hold and settles the held batch with the queue", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const { threadId, parentRunId } = yield* seedActiveCodexParent("held", now); + yield* queueCommandCompletion({ threadId, label: "A" }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`command:coalesce:${threadId}:user`), + threadId, + messageId: MessageId.make(`message:coalesce:${threadId}:user`), + text: "user follow-up", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + // Simulate restart recovery holding the queue while a later run is active. + const beforeRestart = yield* orchestrator.getThreadProjection(threadId); + yield* sink.commitCommand({ + commandId: CommandId.make(`command:coalesce:${threadId}:restart`), + threadId, + commandType: "provider-runtime.reconcile", + acceptedAt: now, + events: beforeRestart.runs + .filter((run) => run.status === "queued") + .map((run) => ({ + id: EventId.make(`event:coalesce:${threadId}:restart:${run.id}`), + type: "run.updated" as const, + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, queueHeld: true }, + })), + effects: [], + }); + yield* queueCommandCompletion({ threadId, label: "B" }); + const held = yield* orchestrator.getThreadProjection(threadId); + const batch = held.messages.find( + (message) => message.id === `message:coalesce:${threadId}:A`, + )!; + assert.equal(batch.notification?.detail, "A\n\nB"); + const batchRun = held.runs.find((run) => run.userMessageId === batch.id)!; + assert.equal(batchRun.status, "queued"); + assert.isTrue(batchRun.queueHeld); + assert.isFalse( + held.messages.some((message) => message.id === `message:coalesce:${threadId}:B`), + ); + const userRun = held.runs.find( + (run) => run.userMessageId === `message:coalesce:${threadId}:user`, + )!; + assert.equal(userRun.status, "queued"); + assert.equal( + held.messages.find((message) => message.id === userRun.userMessageId)?.text, + "user follow-up", + ); + const parent = held.runs.find((run) => run.id === parentRunId)!; + yield* sink.write({ + events: [ + { + id: EventId.make(`event:coalesce:${threadId}:parent-complete`), + type: "run.updated", + threadId, + runId: parentRunId, + occurredAt: now, + payload: { ...parent, status: "completed", completedAt: now }, + }, + ], + }); + // The held user message still blocks settling; the batch does not stand in for it. + const settle = yield* Effect.result( + orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make(`command:coalesce:${threadId}:settle`), + threadId, + }), + ); + assert.equal(settle._tag, "Failure"); + yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make(`command:coalesce:${threadId}:user-cancel`), + threadId, + runId: userRun.id, + }); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make(`command:coalesce:${threadId}:settle-2`), + threadId, + }); + const settled = yield* orchestrator.getThreadProjection(threadId); + assert.isNotNull(settled.thread.settledAt); + assert.equal(settled.runs.find((run) => run.id === batchRun.id)?.status, "cancelled"); + }), + ); it.effect("acceptance batches pending siblings without acknowledging their results", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index caf067a67d3b..e1b840277917 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -419,6 +419,22 @@ function delegatedCompletionWakeDetail(taskIds: ReadonlyArray): string { : `Delegated tasks ${taskList} reached terminal states. Use task_status with each taskId to read the results.`; } +type NotificationOutcome = NonNullable< + OrchestrationV2ConversationMessage["notification"] +>["outcome"]; + +// A command batch reports its most severe outcome; agreeing outcomes pass through. +function backgroundCommandBatchOutcome( + previous: NotificationOutcome, + incoming: NotificationOutcome, +): NotificationOutcome { + if (previous === "failed" || incoming === "failed") return "failed"; + if (previous === "cancelled" || incoming === "cancelled") return "cancelled"; + if (previous === incoming) return previous; + if (previous === "unknown" || incoming === "unknown") return "unknown"; + return "updated"; +} + function isTerminalDelegatedTaskStatus(status: OrchestrationV2Subagent["status"]): boolean { return ( status === "completed" || @@ -4897,6 +4913,74 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio capabilities: queuedCapabilities, }), ); + // Dispatch and promotion share the thread lock. Merge only a still-queued + // Codex command completion wake with the same sender, selection and + // provider thread; if promotion won, queue a new delivery normally. User + // and delegated-completion runs never absorb a batch, a batch keeps its + // run's hold state, and public queued-run.edit keeps rejecting + // notifications. + if ( + queuedAdapter.driver === "codex" && + command.createdBy === "agent" && + command.creationSource === "server" && + command.notification?.source.kind === "command" && + delegatedCompletion === undefined && + command.attachments.length === 0 && + command.context === undefined && + command.sourcePlanRef === undefined && + command.scheduledTaskId === undefined + ) { + const batchRun = queuedRunsInDeliveryOrder(projection).find((candidate) => { + const message = projection.messages.find((row) => row.id === candidate.userMessageId); + return ( + candidate.providerThreadId === targetProviderThread?.id && + modelSelectionsEqual(candidate.modelSelection, modelSelection) && + candidate.sourcePlanRef === undefined && + message?.createdBy === command.createdBy && + message.creationSource === command.creationSource && + message.senderThreadId === command.senderThreadId && + message.delegatedCompletion === undefined && + message.attachments.length === 0 && + message.context === undefined && + message.scheduledTaskId === undefined && + message.notification?.source.kind === "command" + ); + }); + const batchMessage = projection.messages.find( + (row) => row.id === batchRun?.userMessageId, + ); + if (batchRun !== undefined && batchMessage?.notification !== undefined) { + const previous = batchMessage.notification; + const incoming = command.notification; + yield* emit( + events, + command, + )({ + type: "message.updated", + threadId: command.threadId, + runId: batchRun.id, + ...(batchMessage.nodeId === null ? {} : { nodeId: batchMessage.nodeId }), + providerInstanceId: batchRun.providerInstanceId, + occurredAt: now, + payload: { + ...batchMessage, + text: `${batchMessage.text}\n\n${command.text}`, + notification: { + // A batch has no single native command identity. + source: { kind: "command" }, + outcome: backgroundCommandBatchOutcome(previous.outcome, incoming.outcome), + summary: "Background commands finished", + detail: [ + previous.detail ?? previous.summary, + incoming.detail ?? incoming.summary, + ].join("\n\n"), + }, + updatedAt: now, + }, + }); + return; + } + } const queuedProviderThread: OrchestrationV2ProviderThread = targetProviderThread ?? { id: idAllocator.derive.providerThread({ driver: queuedAdapter.driver, diff --git a/apps/server/src/orchestration-v2/testkit/CodexBackgroundCoalescing.integration.test.ts b/apps/server/src/orchestration-v2/testkit/CodexBackgroundCoalescing.integration.test.ts new file mode 100644 index 000000000000..0ea14a81d00b --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/CodexBackgroundCoalescing.integration.test.ts @@ -0,0 +1,212 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; + +import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; +import { EventSinkV2 } from "../EventSink.ts"; +import { layer as idAllocatorLayer } from "../IdAllocator.ts"; +import { OrchestratorV2 } from "../Orchestrator.ts"; +import { + ProviderContinuationRequests, + type ProviderContinuationRequest, +} from "../ProviderContinuationRequests.ts"; +import { workerLive as continuationWorker } from "../ProviderContinuationService.ts"; +import { ThreadManagementService } from "../ThreadManagementService.ts"; +import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; +import { CODEX_MODEL_SELECTION, materializeFixtureInput } from "./fixtures/shared.ts"; +import { runOrchestratorV2Scenario } from "./OrchestratorScenario.ts"; +import { makeProviderReplayGate } from "./ProviderReplayGate.testkit.ts"; +import { makeOrchestratorV2ProviderReplayLayer } from "./ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./ReplayFixtureWorkspace.ts"; +import { + materializeReplayTranscriptWorkspace, + readProviderReplayTranscript, +} from "./ReplayTranscriptNdjson.ts"; + +it.effect("coalesces two replayed Codex command completions behind an active turn", () => + Effect.gen(function* () { + const name = "codex_background_command_coalescing"; + const workspace = yield* checkpointWorkspace(name); + const raw = yield* readProviderReplayTranscript( + new URL( + "./fixtures/codex_background_command_coalescing/codex_transcript.ndjson", + import.meta.url, + ), + ); + const transcript = yield* CodexOrchestratorReplayHarness.decodeTranscript( + materializeReplayTranscriptWorkspace(raw, workspace), + ); + const materialized = yield* materializeFixtureInput({ + scenario: name, + fixtureInput: { + steps: [ + { type: "message", text: "Start two background commands." }, + { type: "message", text: "Keep working while commands finish." }, + ], + }, + driver: ProviderDriverKind.make("codex"), + modelSelection: CODEX_MODEL_SELECTION, + }); + const scenario = { + ...materialized, + name, + transcript, + runtimePolicyOverride: { cwd: workspace }, + }; + const gate = makeProviderReplayGate(["after-coalescing"]); + yield* Effect.addFinalizer(() => Effect.sync(() => gate.releaseAll())); + + // The generic replay harness intentionally omits continuation delivery. + // Share a real mailbox between the adapter and the production worker here; + // forwarding the two thread-service operations retains real dispatch, + // locking, persistence, promotion, provider startup and checkpointing. + const requests = yield* Queue.unbounded(); + const requestLayer = Layer.succeed(ProviderContinuationRequests, { + offer: (request) => Queue.offer(requests, request).pipe(Effect.asVoid), + take: Queue.take(requests), + }); + const replayLayer = makeOrchestratorV2ProviderReplayLayer( + scenario, + { + ...CodexOrchestratorReplayHarness, + makeProviderAdapterRegistryLayer: (value, options) => + CodexOrchestratorReplayHarness.makeProviderAdapterRegistryLayer(value, options).pipe( + Layer.provide(requestLayer), + ), + }, + { replayGate: gate }, + ); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const sink = yield* EventSinkV2; + yield* Layer.build( + continuationWorker.pipe( + Layer.provide(requestLayer), + Layer.provide(idAllocatorLayer), + Layer.provide( + Layer.mock(ThreadManagementService)({ + dispatch: orchestrator.dispatch, + getThreadRecords: orchestrator.getThreadRecords, + }), + ), + ), + ); + const scenarioFiber = yield* runOrchestratorV2Scenario(scenario).pipe(Effect.forkScoped); + // Wait for both constituent completions to be written, merged or not, + // so a missing batch fails by assertion rather than by timeout. + yield* sink.stream({ afterSequence: 0, eventType: "message.updated" }).pipe( + Stream.filter( + ({ event }) => + event.type === "message.updated" && + event.payload.notification?.source.kind === "command", + ), + Stream.take(2), + Stream.runDrain, + ); + const threadId = materialized.projectionThreadIds[0]!; + const queued = yield* orchestrator.getThreadProjection(threadId); + const notifications = queued.messages.filter((message) => message.notification !== undefined); + assert.lengthOf(notifications, 1); + const notification = notifications[0]!; + assert.include(notification.text, "ALPHA_OUTPUT"); + assert.include(notification.text, "BRAVO_OUTPUT"); + assert.equal(notification.text.split("BRAVO_OUTPUT").length, 2); + assert.deepEqual(notification.notification, { + source: { kind: "command" }, + outcome: "failed", + summary: "Background commands finished", + detail: "echo ALPHA\n\necho BRAVO", + }); + assert.equal( + queued.runs.find((run) => run.userMessageId === notification.id)?.status, + "queued", + ); + assert.isTrue(queued.runs.some((run) => run.status === "running")); + // Queue-after-active persists a message and run, not an early activity row. + assert.isEmpty(queued.turnItems.filter((item) => item.runId === notification.runId)); + // Codex completions stay message_text wakes: server-created, and each + // constituent continuation keeps its own accepted receipt on the batch. + assert.equal(notification.createdBy, "agent"); + assert.equal(notification.creationSource, "server"); + const latest = yield* sink.latestSequence({ threadId }); + const stored = yield* sink.stream({ afterSequence: 0, threadId }).pipe( + Stream.takeUntil(({ sequence }) => sequence >= latest), + Stream.runCollect, + ); + const constituents = Array.from(stored).filter( + ({ event }) => event.type === "message.updated" && event.payload.id === notification.id, + ); + assert.lengthOf(constituents, 2); + const receipts = new Set(constituents.map(({ commandId }) => commandId)); + assert.equal(receipts.size, 2); + for (const commandId of receipts) { + assert.isTrue(commandId?.startsWith("provider-continuation:")); + const replayed = yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: commandId!, + threadId, + messageId: notification.id, + text: "a replayed receipt must not merge again", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + }); + assert.isTrue( + replayed.storedEvents.some( + ({ event }) => event.type === "message.updated" && event.payload.id === notification.id, + ), + ); + } + assert.deepEqual( + (yield* orchestrator.getThreadProjection(threadId)).messages.find( + (message) => message.id === notification.id, + ), + notification, + ); + assert.isTrue(gate.release("after-coalescing")); + const result = yield* Fiber.join(scenarioFiber); + const projection = result.projections.get(threadId)!; + assert.deepEqual( + projection.runs.map((run) => run.status), + ["completed", "completed", "completed"], + ); + const items = projection.turnItems.filter((item) => item.type === "notification"); + assert.lengthOf(items, 1); + assert.equal(items[0]?.summary, "Background commands finished"); + assert.equal(items[0]?.detail, "echo ALPHA\n\necho BRAVO"); + assert.equal(items[0]?.outcome, "failed"); + assert.equal(items[0]?.runId, notification.runId); + // Batching a notification does not collapse the command output rows. + const commands = projection.turnItems.filter((item) => item.type === "command_execution"); + assert.lengthOf(commands, 2); + assert.sameMembers( + commands.map((item) => item.input), + ["echo ALPHA", "echo BRAVO"], + ); + assert.sameMembers( + commands.map((item) => item.output), + ["ALPHA_OUTPUT\n", "BRAVO_OUTPUT\n"], + ); + assert.notEqual(commands[0]?.id, commands[1]?.id); + // Command results stay owned by the root run that started them. + assert.sameMembers( + commands.map((item) => item.runId), + [projection.runs[0]!.id, projection.runs[0]!.id], + ); + assert.notEqual(projection.runs[0]!.id, notification.runId); + assert.isTrue( + projection.messages.some((message) => message.text === "Both command results received."), + ); + }).pipe(Effect.provide(replayLayer)); + }).pipe( + Effect.provide(Layer.mergeAll(idAllocatorLayer, NodeServices.layer)), + provideDeterministicTestRuntime, + Effect.scoped, + ), +); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/codex_background_command_coalescing/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/codex_background_command_coalescing/codex_transcript.ndjson new file mode 100644 index 000000000000..a5ee456166aa --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/codex_background_command_coalescing/codex_transcript.ndjson @@ -0,0 +1,25 @@ +{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"codex_background_command_coalescing","metadata":{"source":"synthetic-regression","description":"Two commands from a settled root complete behind an active follow-up. Gate that follow-up until the real continuation worker durably coalesces both notifications."}} +{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} +{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} +{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} +{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true}}}} +{"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"019dadec-c14b-7b13-b96c-92c98ccfc342","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739467,"updatedAt":1776739467,"status":{"type":"idle"},"path":"/Users/julius/.codex/sessions/2026/04/20/rollout-2026-04-20T19-44-27-019dadec-c14b-7b13-b96c-92c98ccfc342.jsonl","cwd":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1/packages/effect-codex-app-server","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":"fast","cwd":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1/packages/effect-codex-app-server","approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":["/Users/julius/.codex/memories"],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"reasoningEffort":"xhigh"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Start two background commands.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","approvalsReviewer":"user","summary":"detailed"}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"background-root","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","turn":{"id":"background-root","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","turnId":"background-root","item":{"type":"commandExecution","id":"command-ALPHA","command":"echo ALPHA","cwd":"","processId":"process-ALPHA","status":"inProgress","commandActions":[],"aggregatedOutput":"","exitCode":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","turnId":"background-root","item":{"type":"commandExecution","id":"command-BRAVO","command":"echo BRAVO","cwd":"","processId":"process-BRAVO","status":"inProgress","commandActions":[],"aggregatedOutput":"","exitCode":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","turn":{"id":"background-root","items":[],"status":"completed","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Keep working while commands finish.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","approvalsReviewer":"user","summary":"detailed"}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"active-follow-up","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","turn":{"id":"active-follow-up","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","turnId":"background-root","item":{"type":"commandExecution","id":"command-ALPHA","command":"echo ALPHA","cwd":"","processId":"process-ALPHA","status":"completed","commandActions":[],"aggregatedOutput":"ALPHA_OUTPUT\n","exitCode":0,"durationMs":1}}}} +{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","turnId":"background-root","item":{"type":"commandExecution","id":"command-BRAVO","command":"echo BRAVO","cwd":"","processId":"process-BRAVO","status":"completed","commandActions":[],"aggregatedOutput":"BRAVO_OUTPUT\n","exitCode":1,"durationMs":1}}}} +{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","turnId":"background-root","item":{"type":"commandExecution","id":"command-BRAVO","command":"echo BRAVO","cwd":"","processId":"process-BRAVO","status":"completed","commandActions":[],"aggregatedOutput":"BRAVO_OUTPUT\n","exitCode":1,"durationMs":1}}}} +{"type":"emit_inbound","label":"after-coalescing","frame":{"method":"turn/completed","params":{"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","turn":{"id":"active-follow-up","items":[],"status":"completed","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Background command completed (exit 0): echo ALPHA\n\nOutput tail:\nALPHA_OUTPUT\n\nBackground command completed (exit 1): echo BRAVO\n\nOutput tail:\nBRAVO_OUTPUT","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","approvalsReviewer":"user","summary":"detailed"}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":5,"result":{"turn":{"id":"combined-notification","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","turn":{"id":"combined-notification","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","turnId":"combined-notification","item":{"type":"agentMessage","id":"combined-response","text":"Both command results received.","phase":"final_answer","memoryCitation":null}}}} +{"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019dadec-c14b-7b13-b96c-92c98ccfc342","turn":{"id":"combined-notification","items":[],"status":"completed","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"runtime_exit","status":"success"} From fd3d5863fd3d64eada1e904a0baaacf39aaf0bd2 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Fri, 25 Sep 2026 14:35:42 -0400 Subject: [PATCH 21/34] feat(orchestrator): Page capability catalogs on demand --- .../src/mcp/OrchestratorMcpService.test.ts | 104 ++++++++++++- apps/server/src/mcp/OrchestratorMcpService.ts | 102 +++++++++++-- ...OrchestratorMcpToolkit.integration.test.ts | 141 +++++++++++++++--- .../src/mcp/toolkits/orchestrator/handlers.ts | 4 +- .../mcp/toolkits/orchestrator/tools.test.ts | 13 ++ .../src/mcp/toolkits/orchestrator/tools.ts | 6 +- .../T3OrchestrationInstructions.test.ts | 6 + .../provider/T3OrchestrationInstructions.ts | 2 +- .../orchestrator-mcp-server.md | 10 +- .../contracts/src/orchestratorMcp.test.ts | 76 ++++++++++ packages/contracts/src/orchestratorMcp.ts | 59 ++++++-- 11 files changed, 472 insertions(+), 51 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 3f927c283ef8..1ac257c10cb3 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -518,13 +518,25 @@ describe("OrchestratorMcpService provider resolution", () => { yield* Effect.gen(function* () { const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const capabilities = yield* service.capabilities(scope); + const capabilities = yield* service.capabilities(scope, {}); const byId = new Map( capabilities.providers.map((provider) => [provider.providerInstanceId, provider]), ); + // Models are paged per provider; the no-argument response omits them. + const expandedModelIds = (instanceId: ProviderInstanceId) => + service + .capabilities(scope, { providerInstanceId: instanceId }) + .pipe( + Effect.map((result) => + result.providers + .find((entry) => entry.providerInstanceId === instanceId) + ?.models?.map((model) => model.id), + ), + ); for (const provider of providers) { + assert.isUndefined(byId.get(provider.instanceId)?.models); assert.deepEqual( - byId.get(provider.instanceId)?.models.map((model) => model.id), + yield* expandedModelIds(provider.instanceId), provider.models.map((model) => model.slug), ); } @@ -541,12 +553,9 @@ describe("OrchestratorMcpService provider resolution", () => { }, ], })); - const refreshed = yield* service.capabilities(scope); for (const provider of providers) { assert.deepEqual( - refreshed.providers - .find((entry) => entry.providerInstanceId === provider.instanceId) - ?.models.map((model) => model.id), + yield* expandedModelIds(provider.instanceId), provider.models.map((model) => model.slug), ); } @@ -587,6 +596,89 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("expands an empty catalog without dropping provider setup constraints", () => + Effect.gen(function* () { + const forkOnlyInstanceId = ProviderInstanceId.make("forkOnly"); + const forkShadow = yield* buildUnavailableProviderSnapshot({ + driverKind: "forkOnly", + instanceId: forkOnlyInstanceId, + reason: "Driver 'forkOnly' is not registered in this build.", + checkedAt: "2026-09-13T00:00:00.000Z", + }); + const unauthenticatedInstanceId = ProviderInstanceId.make("claudeAgent"); + const codexProvider = providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }); + const providers: ReadonlyArray = [ + { + ...codexProvider, + models: ["gpt-5.4", "gpt-5.4-mini", "gpt-5.5"].map((slug) => ({ + ...codexProvider.models[0]!, + slug, + name: slug, + })), + }, + { + ...providerSnapshot({ + instanceId: unauthenticatedInstanceId, + driver: ProviderDriverKind.make("claudeAgent"), + }), + auth: { status: "unauthenticated" }, + }, + forkShadow, + ]; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection([])), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed(providers) }), + adapterRegistryLayer([codexInstanceId, unauthenticatedInstanceId]), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const unauthenticated = yield* service.capabilities(scope, { + providerInstanceId: unauthenticatedInstanceId, + }); + const expanded = unauthenticated.providers.find( + (provider) => provider.providerInstanceId === unauthenticatedInstanceId, + ); + assert.deepInclude(expanded, { models: [], modelsNextCursor: null, modelsTotal: 0 }); + assert.isFalse(expanded!.canRunChildTask); + assert.deepEqual(expanded!.constraints, ["Provider is not authenticated."]); + for (const provider of unauthenticated.providers) { + if (provider.providerInstanceId !== unauthenticatedInstanceId) { + assert.notProperty(provider, "models"); + } + } + + const fork = (yield* service.capabilities(scope, { + providerInstanceId: forkOnlyInstanceId, + modelCursor: 3, + })).providers.find((provider) => provider.providerInstanceId === forkOnlyInstanceId); + assert.deepInclude(fork, { models: [], modelsNextCursor: null, modelsTotal: 0 }); + assert.include(fork!.constraints, "No V2 provider adapter is registered."); + assert.include(fork!.constraints, "Driver 'forkOnly' is not registered in this build."); + + const exact = (yield* service.capabilities(scope, { + providerInstanceId: codexInstanceId, + model: "gpt-5.4-mini", + modelCursor: 2, + modelLimit: 1, + })).providers.find((provider) => provider.providerInstanceId === codexInstanceId); + assert.deepInclude(exact, { + models: [{ id: "gpt-5.4-mini", label: "gpt-5.4-mini" }], + modelsNextCursor: null, + modelsTotal: 1, + }); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); + it.effect( "delegates to an Antigravity instance whose adapter resolves through the registry", () => diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 5fdc7eb81c80..40c16bdc7e24 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -11,6 +11,7 @@ import { type OrchestrationV2ThreadShell, type OrchestrationV2TurnItem, OrchestratorMcpFailure, + type OrchestratorMcpCapabilitiesInput, type OrchestratorMcpCapabilitiesResult, type OrchestratorMcpCreateThreadsInput, type OrchestratorMcpCreateThreadsResult, @@ -71,6 +72,8 @@ import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; +const DEFAULT_CAPABILITIES_MODEL_LIMIT = 50; +const MAX_CAPABILITIES_MODEL_LIMIT = 100; const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000; const TASK_POLL_INTERVAL_MS = 50; @@ -91,6 +94,7 @@ type TerminalTaskStatus = Extract< export interface OrchestratorMcpServiceShape { readonly capabilities: ( scope: McpInvocationScope, + input: OrchestratorMcpCapabilitiesInput, ) => Effect.Effect; readonly delegateTask: ( scope: McpInvocationScope, @@ -229,6 +233,43 @@ function providerConstraints( return constraints; } +function capabilityModelCatalog( + provider: ServerProvider, + input: { + readonly model: string | undefined; + readonly modelCursor: number | undefined; + readonly modelLimit: number | undefined; + readonly includeModelOptions: boolean; + }, +) { + const matchingModels = + input.model === undefined + ? provider.models + : provider.models.filter((model) => model.slug === input.model); + const cursor = input.model === undefined ? (input.modelCursor ?? 0) : 0; + const requestedLimit = input.modelLimit ?? DEFAULT_CAPABILITIES_MODEL_LIMIT; + const limit = + input.model === undefined ? Math.min(requestedLimit, MAX_CAPABILITIES_MODEL_LIMIT) : 1; + const page = matchingModels.slice(cursor, cursor + limit); + const nextCursor = cursor + page.length < matchingModels.length ? cursor + page.length : null; + const models = page.map((model) => { + const summary = { + id: model.slug, + label: model.name ?? null, + }; + const optionDescriptors = model.capabilities?.optionDescriptors; + if (!input.includeModelOptions || optionDescriptors === undefined) { + return summary; + } + return { ...summary, options: optionDescriptors }; + }); + return { + models, + modelsNextCursor: nextCursor, + modelsTotal: matchingModels.length, + }; +} + /** * Checks requested option selections for duplicates and, when the model * advertises option descriptors, against those descriptors. Models without @@ -1307,12 +1348,53 @@ const make = Effect.gen(function* () { ); return { scheduledTaskId: existing.id, deleted: true }; }), - capabilities: (scope) => + capabilities: (scope, input) => Effect.gen(function* () { yield* requireCapability(scope); const parent = yield* loadProjection(scope.threadId); const providers = yield* loadProviders; const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); + const expandedProviderInstanceId = input.providerInstanceId ?? undefined; + const requestedModel = input.model ?? undefined; + const modelCursor = input.modelCursor ?? undefined; + const modelLimit = input.modelLimit ?? undefined; + const includeModelOptions = input.includeModelOptions ?? false; + const expandedProvider = providers.find( + (provider) => provider.instanceId === expandedProviderInstanceId, + ); + if ( + expandedProviderInstanceId === undefined && + (requestedModel !== undefined || + modelCursor !== undefined || + modelLimit !== undefined || + includeModelOptions) + ) { + return yield* failure( + "invalid_request", + "providerInstanceId is required to expand a model catalog.", + ); + } + if (expandedProviderInstanceId !== undefined && expandedProvider === undefined) { + return yield* failure( + "provider_unavailable", + `Provider instance ${expandedProviderInstanceId} is not registered.`, + ); + } + if (includeModelOptions && requestedModel === undefined) { + return yield* failure( + "invalid_request", + "includeModelOptions requires an exact model id.", + ); + } + if ( + requestedModel !== undefined && + !expandedProvider?.models.some((model) => model.slug === requestedModel) + ) { + return yield* failure( + "model_unavailable", + `Model ${requestedModel} is not advertised by provider ${expandedProviderInstanceId}.`, + ); + } return { parentThreadId: scope.threadId, inheritedProviderInstanceId: parent.thread.modelSelection.instanceId, @@ -1327,15 +1409,15 @@ const make = Effect.gen(function* () { return { providerInstanceId: provider.instanceId, driverKind: provider.driver, - displayName: provider?.displayName ?? null, - models: - provider?.models.map((model) => ({ - id: model.slug, - label: model.name ?? null, - ...(model.capabilities?.optionDescriptors === undefined - ? {} - : { options: model.capabilities.optionDescriptors }), - })) ?? [], + displayName: provider.displayName ?? null, + ...(provider.instanceId === expandedProviderInstanceId + ? capabilityModelCatalog(provider, { + model: requestedModel, + modelCursor, + modelLimit, + includeModelOptions, + }) + : {}), canRunChildTask: constraints.length === 0, canRunCrossProviderChildTask: constraints.length === 0, constraints: [...constraints], diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 7d20bf512b10..28dbb7de5f61 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -13,6 +13,7 @@ import { type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, type OrchestrationV2ThreadProjection, + OrchestratorMcpCapabilitiesResult, OrchestratorMcpCreateThreadsResult, OrchestratorMcpDelegateTaskResult, OrchestratorMcpTaskCancelResult, @@ -90,6 +91,7 @@ const createdThreadPrompt = "Complete the newly created ordinary thread."; const queuedFollowupPrompt = "Complete the queued follow-up and return the final result."; const queuedFollowupResult = "Queued delegated follow-up completed."; +const decodeCapabilitiesResult = Schema.decodeUnknownEffect(OrchestratorMcpCapabilitiesResult); const decodeCreateThreadsResult = Schema.decodeUnknownEffect(OrchestratorMcpCreateThreadsResult); const decodeDelegateTaskResult = Schema.decodeUnknownEffect(OrchestratorMcpDelegateTaskResult); const decodeTaskCancelResult = Schema.decodeUnknownEffect(OrchestratorMcpTaskCancelResult); @@ -102,6 +104,16 @@ const decodeThreadSendResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadS const decodeThreadWaitResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadWaitResult); const decodeThreadUpdateResult = Schema.decodeUnknownEffect(ThreadMetadataMcpUpdateResult); +type ProviderCapability = OrchestratorMcpCapabilitiesResult["providers"][number]; + +function defaultProvidersAreSlim(result: OrchestratorMcpCapabilitiesResult): boolean { + return result.providers.every((provider) => !("models" in provider)); +} + +function providerCapabilityModels(provider: ProviderCapability | undefined) { + return provider !== undefined && "models" in provider ? provider.models : undefined; +} + const codexSelection = { instanceId: codexInstanceId, model: codexModel, @@ -142,6 +154,7 @@ function makeProviderSnapshot(input: { readonly instanceId: ProviderInstanceId; readonly driver: ProviderDriverKind; readonly model: string; + readonly additionalModels?: ReadonlyArray; readonly optionDescriptors?: ReadonlyArray; }): ServerProvider { return { @@ -153,17 +166,18 @@ function makeProviderSnapshot(input: { status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-06-17T00:00:00.000Z", - models: [ - { - slug: input.model, - name: input.model, + models: [input.model, ...(input.additionalModels ?? [])].map((model, index) => { + const capabilities = + index === 0 && input.optionDescriptors !== undefined + ? { optionDescriptors: input.optionDescriptors } + : null; + return { + slug: model, + name: model, isCustom: false, - capabilities: - input.optionDescriptors === undefined - ? null - : { optionDescriptors: input.optionDescriptors }, - }, - ], + capabilities, + }; + }), slashCommands: [], skills: [], }; @@ -593,7 +607,11 @@ describe("orchestrator MCP toolkit", () => { makeProviderSnapshot({ instanceId: ProviderInstanceId.make("opencode"), driver: ProviderDriverKind.make("opencode"), - model: "opencode/test", + model: "opencode/test-000", + additionalModels: Array.from( + { length: 124 }, + (_, index) => `opencode/test-${String(index + 1).padStart(3, "0")}`, + ), }), ]); // In-memory ScheduledTaskService stub so the schedule/list/update/ @@ -1349,20 +1367,105 @@ describe("orchestrator MCP toolkit", () => { providerInstanceId: "opencode", canRunChildTask: false, }), - // Models advertise their option descriptors so agents can - // discover valid target.options ids and values. expect.objectContaining({ providerInstanceId: codexInstanceId, - models: [ - expect.objectContaining({ - id: codexModel, - options: [expect.objectContaining({ id: "reasoning", type: "select" })], - }), - ], + canRunChildTask: true, }), ]), }); + expect( + defaultProvidersAreSlim( + yield* decodeCapabilitiesResult(capabilities.structuredContent), + ), + ).toBe(true); + const nullCapabilities = yield* invoke("orchestrator_capabilities", { + providerInstanceId: null, + model: null, + modelCursor: null, + modelLimit: null, + includeModelOptions: null, + }); + expect(nullCapabilities.structuredContent).toEqual(capabilities.structuredContent); + for (const [cursor, limit, count, next] of [ + [0, undefined, 50, 50], + [100, 50, 25, null], + [200, 50, 0, null], + [0, 100, 100, 100], + ] as const) { + const page = yield* invoke("orchestrator_capabilities", { + providerInstanceId: "opencode", + modelCursor: cursor, + ...(limit === undefined ? {} : { modelLimit: limit }), + }); + const result = yield* decodeCapabilitiesResult(page.structuredContent); + const provider = result.providers.find((p) => p.providerInstanceId === "opencode"); + expect(providerCapabilityModels(provider)).toHaveLength(count); + expect(provider).toMatchObject({ modelsNextCursor: next, modelsTotal: 125 }); + expect( + providerCapabilityModels(provider)?.every((m) => m.options === undefined), + ).toBe(true); + expect( + result.providers.find((p) => p.providerInstanceId === codexInstanceId), + ).not.toHaveProperty("models"); + } + const exactModel = yield* invoke("orchestrator_capabilities", { + providerInstanceId: codexInstanceId, + model: codexModel, + }); + const exactResult = yield* decodeCapabilitiesResult(exactModel.structuredContent); + expect( + providerCapabilityModels( + exactResult.providers.find((p) => p.providerInstanceId === codexInstanceId), + ), + ).toEqual([{ id: codexModel, label: codexModel }]); + const modelOptions = yield* invoke("orchestrator_capabilities", { + providerInstanceId: codexInstanceId, + model: codexModel, + includeModelOptions: true, + }); + const optionsResult = yield* decodeCapabilitiesResult(modelOptions.structuredContent); + expect( + providerCapabilityModels( + optionsResult.providers.find((p) => p.providerInstanceId === codexInstanceId), + ), + ).toMatchObject([ + { id: codexModel, options: [expect.objectContaining({ id: "reasoning" })] }, + ]); + + const missingCatalogProvider = yield* invoke("orchestrator_capabilities", { + model: codexModel, + }); + expect(missingCatalogProvider.structuredContent).toMatchObject({ + _tag: "OrchestratorMcpFailure", + code: "invalid_request", + message: expect.stringContaining("providerInstanceId is required"), + }); + const unboundedOptions = yield* invoke("orchestrator_capabilities", { + providerInstanceId: codexInstanceId, + includeModelOptions: true, + }); + expect(unboundedOptions.structuredContent).toMatchObject({ + _tag: "OrchestratorMcpFailure", + code: "invalid_request", + message: expect.stringContaining("requires an exact model id"), + }); + const missingProvider = yield* invoke("orchestrator_capabilities", { + providerInstanceId: "missing-provider", + }); + expect(missingProvider.structuredContent).toMatchObject({ + _tag: "OrchestratorMcpFailure", + code: "provider_unavailable", + }); + const missingModel = yield* invoke("orchestrator_capabilities", { + providerInstanceId: codexInstanceId, + model: "missing-model", + }); + expect(missingModel.structuredContent).toMatchObject({ + _tag: "OrchestratorMcpFailure", + code: "model_unavailable", + }); + const scheduleTool = server.tools.find(({ tool }) => tool.name === "schedule_task"); expect(scheduleTool?.tool.annotations?.destructiveHint).toBe(true); const scheduleCall = yield* invoke("schedule_task", { diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 7dbbd21cb624..d35d191b4a23 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -6,11 +6,11 @@ import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; const handlers = { - orchestrator_capabilities: () => + orchestrator_capabilities: (input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.capabilities(scope); + return yield* service.capabilities(scope, input); }), delegate_task: (input) => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts index de85b6f72e0c..18d91b50b5b1 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -4,6 +4,7 @@ import { Tool } from "effect/unstable/ai"; import { CreateThreadsTool, DelegateTaskTool, + OrchestratorCapabilitiesTool, ScheduleTaskTool, ThreadUpdateTool, } from "./tools.ts"; @@ -46,6 +47,18 @@ describe("orchestrator MCP tool guidance", () => { assert.include(timeoutText, "does not cancel the child"); }); + it("documents slim and paginated capability discovery", () => { + const schema = Tool.getJsonSchema(OrchestratorCapabilitiesTool) as { + readonly properties?: Readonly>; + }; + + assert.isString(schema.properties?.providerInstanceId?.description); + assert.isString(schema.properties?.model?.description); + assert.include(OrchestratorCapabilitiesTool.description ?? "", "no-argument response"); + assert.include(OrchestratorCapabilitiesTool.description ?? "", "modelCursor=modelsNextCursor"); + assert.include(OrchestratorCapabilitiesTool.description ?? "", "includeModelOptions=true"); + }); + it("publishes an actionable schedule schema and compatibility string branch", () => { const schema = Tool.getJsonSchema(ScheduleTaskTool) as { readonly type?: unknown; diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 833e88bd2ae8..35ea7a2c0f83 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -1,4 +1,5 @@ import { + OrchestratorMcpCapabilitiesInput, OrchestratorMcpCapabilitiesResult, OrchestratorMcpCreateThreadsInput, OrchestratorMcpCreateThreadsResult, @@ -42,9 +43,10 @@ const threadMetadataDependencies = [ ThreadMetadataMcpService.ThreadMetadataMcpService, ]; -const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { +export const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { description: - "List the V2 provider instances and their current models from the same live catalog as the composer, including configured custom models, inherited runtime settings, and app-owned orchestration features available to this T3 thread. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.", + "List V2 provider summaries, inherited runtime settings, and app-owned orchestration features for this T3 thread. The no-argument response omits model catalogs. Pass providerInstanceId to read that provider's paginated models from the same live catalog as the composer, including configured custom models; continue with modelCursor=modelsNextCursor, or add an exact model with includeModelOptions=true to inspect its option descriptors. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.", + parameters: OrchestratorMcpCapabilitiesInput, success: OrchestratorMcpCapabilitiesResult, failure: OrchestratorMcpFailure, failureMode: "return", diff --git a/apps/server/src/provider/T3OrchestrationInstructions.test.ts b/apps/server/src/provider/T3OrchestrationInstructions.test.ts index dbcd209a136f..8382e7e03ec8 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.test.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.test.ts @@ -15,6 +15,12 @@ describe("T3 orchestration provider instructions", () => { assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "cross-provider"); }); + it("documents bounded catalog discovery", () => { + assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "provider summaries"); + assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "modelCursor"); + assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "includeModelOptions: true"); + }); + it("documents structured schedules instead of JSON strings", () => { assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "structured object, never as JSON text"); assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, '"everyMs":3600000'); diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/apps/server/src/provider/T3OrchestrationInstructions.ts index ed510b879c0f..1255229843f5 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.ts @@ -6,7 +6,7 @@ export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = ` The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly: -- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent; do not replace delegation with ordinary thread creation. +- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models: call \`orchestrator_capabilities({})\` for provider summaries, pass \`providerInstanceId\` to page that provider's models with \`modelCursor\`, and request an exact \`model\` with \`includeModelOptions: true\` only when its option descriptors are needed. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent; do not replace delegation with ordinary thread creation. - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. - \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule. By default runs return to the current thread; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling, report the returned cadence and next run time. diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 528fbce4bb64..6d1f2af8c170 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -187,14 +187,20 @@ The server exposes eleven orchestration tools. ### `orchestrator_capabilities` -Returns: +The default `{}` request returns: - the inherited provider instance and model; - the parent runtime and interaction modes; -- registered provider instances and advertised models; +- registered provider summaries without model catalogs; - whether each provider can run a child task; and - feature flags for polling, cancellation, and batch thread creation. +Pass `providerInstanceId` to return that provider's models in pages of 50 by +default and 100 at most. Continue with `modelCursor` set to the prior +`modelsNextCursor`. Pass an exact `model` to return only that model, and add +`includeModelOptions: true` when its option descriptors are needed. Catalog +responses include `modelsTotal` and a nullable `modelsNextCursor`. + Unavailable providers include model-visible constraints such as missing V2 adapter support, disabled state, missing executable, or missing authentication. diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index 617442ecea9d..df5e1015d6c8 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; import { + OrchestratorMcpCapabilitiesInput, + OrchestratorMcpProviderCapability, OrchestratorMcpCreateThreadsInput, OrchestratorMcpDelegateTaskInput, OrchestratorMcpDelegateTaskResult, @@ -12,6 +14,8 @@ import { OrchestratorMcpThreadWaitInput, } from "./orchestratorMcp.ts"; +const decodeCapabilitiesInput = Schema.decodeUnknownSync(OrchestratorMcpCapabilitiesInput); +const decodeProviderCapability = Schema.decodeUnknownSync(OrchestratorMcpProviderCapability); const decodeCreateThreadsInput = Schema.decodeUnknownSync(OrchestratorMcpCreateThreadsInput); const decodeDelegateTaskInput = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskInput); const decodeDelegateTaskResult = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskResult); @@ -22,6 +26,78 @@ const decodeThreadSendInput = Schema.decodeUnknownSync(OrchestratorMcpThreadSend const decodeThreadWaitInput = Schema.decodeUnknownSync(OrchestratorMcpThreadWaitInput); describe("orchestrator MCP contracts", () => { + it("decodes slim and expanded capability requests", () => { + expect(decodeCapabilitiesInput({})).toEqual({}); + expect( + decodeCapabilitiesInput({ + providerInstanceId: "opencode2", + model: "opencode/x-preview-f-free", + modelCursor: 50, + modelLimit: 25, + includeModelOptions: true, + }), + ).toEqual({ + providerInstanceId: "opencode2", + model: "opencode/x-preview-f-free", + modelCursor: 50, + modelLimit: 25, + includeModelOptions: true, + }); + expect( + decodeCapabilitiesInput({ + providerInstanceId: null, + model: null, + modelCursor: null, + modelLimit: null, + includeModelOptions: null, + }), + ).toEqual({ + providerInstanceId: null, + model: null, + modelCursor: null, + modelLimit: null, + includeModelOptions: null, + }); + for (const invalid of [ + { modelLimit: 0 }, + { modelLimit: 101 }, + { modelLimit: 1.5 }, + { modelCursor: -1 }, + { modelCursor: 0.5 }, + ]) { + expect(() => + decodeCapabilitiesInput({ providerInstanceId: "opencode", ...invalid }), + ).toThrow(); + } + }); + + it("requires model catalog fields as a complete result shape", () => { + const summary = { + providerInstanceId: "opencode2", + driverKind: "opencode2", + displayName: "OpenCode 2", + canRunChildTask: true, + canRunCrossProviderChildTask: true, + constraints: [], + }; + + expect(decodeProviderCapability(summary)).toEqual(summary); + expect( + decodeProviderCapability({ + ...summary, + models: [{ id: "opencode/test", label: "Test" }], + modelsNextCursor: null, + modelsTotal: 1, + }), + ).toMatchObject({ modelsTotal: 1, modelsNextCursor: null }); + expect(() => + decodeProviderCapability({ + ...summary, + models: [{ id: "opencode/test", label: "Test" }], + }), + ).toThrow(); + }); + it("decodes cross-provider delegated task requests and durable results", () => { const request = decodeDelegateTaskInput({ task: "Inspect the workspace and report the result.", diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 565f42866466..234cc9344b76 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -96,6 +96,29 @@ export const OrchestratorMcpTargetOptions = Schema.Union([ ]); export type OrchestratorMcpTargetOptions = typeof OrchestratorMcpTargetOptions.Type; +export const OrchestratorMcpCapabilitiesInput = Schema.Struct({ + providerInstanceId: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)).annotate({ + description: + "Provider instance whose model catalog to expand. Omit for the summary-only response.", + }), + model: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)).annotate({ + description: "Optional exact model id to return from the expanded provider catalog.", + }), + modelCursor: Schema.optionalKey(Schema.NullOr(NonNegativeInt)).annotate({ + description: "Zero-based cursor for the expanded model catalog. Defaults 0.", + }), + modelLimit: Schema.optionalKey( + Schema.NullOr(PositiveInt.check(Schema.isLessThanOrEqualTo(100))), + ).annotate({ + description: "Maximum models to return from the expanded catalog. Defaults 50; maximum 100.", + }), + includeModelOptions: Schema.optionalKey(Schema.NullOr(Schema.Boolean)).annotate({ + description: + "Include option descriptors for the exact requested model. Defaults false and requires model.", + }), +}); +export type OrchestratorMcpCapabilitiesInput = typeof OrchestratorMcpCapabilitiesInput.Type; + export const OrchestratorMcpTarget = Schema.Struct({ providerInstanceId: Schema.optional( ProviderInstanceId.annotate({ @@ -456,22 +479,40 @@ export const OrchestratorMcpThreadInterruptResult = Schema.Struct({ }); export type OrchestratorMcpThreadInterruptResult = typeof OrchestratorMcpThreadInterruptResult.Type; -export const OrchestratorMcpProviderCapability = Schema.Struct({ +const OrchestratorMcpProviderCapabilityFields = { providerInstanceId: ProviderInstanceId, driverKind: ProviderDriverKind, displayName: Schema.NullOr(Schema.String), - models: Schema.Array( - Schema.Struct({ - id: Schema.String, - label: Schema.NullOr(Schema.String), - /** Model options a target may select (for example reasoning effort). */ - options: Schema.optional(Schema.Array(ProviderOptionDescriptor)), - }), - ), canRunChildTask: Schema.Boolean, canRunCrossProviderChildTask: Schema.Boolean, constraints: Schema.Array(Schema.String), +}; + +const OrchestratorMcpCapabilityModel = Schema.Struct({ + id: Schema.String, + label: Schema.NullOr(Schema.String), + /** Model options a target may select (for example reasoning effort). */ + options: Schema.optionalKey(Schema.Array(ProviderOptionDescriptor)), +}); + +const OrchestratorMcpProviderSummaryCapability = Schema.Struct({ + ...OrchestratorMcpProviderCapabilityFields, + models: Schema.optionalKey(Schema.Never), + modelsNextCursor: Schema.optionalKey(Schema.Never), + modelsTotal: Schema.optionalKey(Schema.Never), }); + +const OrchestratorMcpProviderCatalogCapability = Schema.Struct({ + ...OrchestratorMcpProviderCapabilityFields, + models: Schema.Array(OrchestratorMcpCapabilityModel), + modelsNextCursor: Schema.NullOr(NonNegativeInt), + modelsTotal: NonNegativeInt, +}); + +export const OrchestratorMcpProviderCapability = Schema.Union([ + OrchestratorMcpProviderCatalogCapability, + OrchestratorMcpProviderSummaryCapability, +]); export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCapability.Type; export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ From ffd78836934c0661157fcf67a82fe06c921cd618 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Fri, 25 Sep 2026 15:28:50 -0400 Subject: [PATCH 22/34] feat(orchestrator): Start threads in known projects with scoped access --- apps/server/src/mcp/McpHttpServer.ts | 3 +- .../OrchestratorMcpService.activity.test.ts | 279 ++++++- .../src/mcp/OrchestratorMcpService.test.ts | 717 ++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 332 ++++++-- ...OrchestratorMcpToolkit.integration.test.ts | 771 +++++++++++++++++- .../mcp/toolkits/orchestrator/tools.test.ts | 17 + .../src/mcp/toolkits/orchestrator/tools.ts | 14 +- .../src/mcp/toolkits/project/handlers.test.ts | 4 + .../src/mcp/toolkits/project/handlers.ts | 43 +- apps/server/src/mcp/toolkits/project/tools.ts | 4 +- .../src/orchestration-v2/Orchestrator.ts | 36 +- .../ThreadLaunchService.test.ts | 62 ++ .../orchestration-v2/ThreadLaunchService.ts | 12 + .../orchestration-v2/ThreadMessageIntake.ts | 6 +- .../T3OrchestrationInstructions.test.ts | 5 + .../provider/T3OrchestrationInstructions.ts | 4 +- .../orchestrator-mcp-server.md | 46 +- .../contracts/src/orchestratorMcp.test.ts | 11 + packages/contracts/src/orchestratorMcp.ts | 12 +- 19 files changed, 2266 insertions(+), 112 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5c8f146bcadf..e2db5b00369a 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -683,8 +683,9 @@ const EnvironmentRegistrationLive = McpServer.toolkit(EnvironmentToolkit).pipe( Layer.provide(EnvironmentHandlersLive), ); -const ProjectRegistrationLive = McpServer.toolkit(ProjectToolkit).pipe( +export const ProjectRegistrationLive = McpServer.toolkit(ProjectToolkit).pipe( Layer.provide(ProjectHandlersLive), + Layer.provide(OrchestratorMcpService.layer), ); const AttachmentRegistrationLive = McpServer.toolkit(AttachmentToolkit).pipe( diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 06eff1e6f8b1..2c50f75f8699 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -7,11 +7,15 @@ import { ProviderInstanceId, RunId, ThreadId, + TurnItemId, + type OrchestratorMcpFailure, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodePath from "@effect/platform-node/NodePath"; +import * as ProjectService from "../project/ProjectService.ts"; import { expect, it } from "vite-plus/test"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; @@ -21,6 +25,11 @@ import * as ThreadManagementService from "../orchestration-v2/ThreadManagementSe import type * as McpInvocationContext from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; +const orchestratorMcpServiceLayer = OrchestratorMcpService.layer.pipe( + Layer.provide(NodePath.layer), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), +); + const environmentId = EnvironmentId.make("environment-mcp-orchestrator-detail"); const projectId = ProjectId.make("project-mcp-orchestrator-detail"); const parentThreadId = ThreadId.make("thread-mcp-orchestrator-parent"); @@ -118,7 +127,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a updatedAt: now, } as unknown as OrchestrationV2ThreadProjection; - const layer = OrchestratorMcpService.layer.pipe( + const layer = orchestratorMcpServiceLayer.pipe( Layer.provide( Layer.mergeAll( Layer.mock(ThreadManagementService.ThreadManagementService)({ @@ -171,7 +180,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run updatedAt: now, } as unknown as OrchestrationV2ThreadProjection; - const layer = OrchestratorMcpService.layer.pipe( + const layer = orchestratorMcpServiceLayer.pipe( Layer.provide( Layer.mergeAll( Layer.mock(ThreadManagementService.ThreadManagementService)({ @@ -281,7 +290,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy updatedAt: now, } as unknown as OrchestrationV2ThreadProjection; - const layer = OrchestratorMcpService.layer.pipe( + const layer = orchestratorMcpServiceLayer.pipe( Layer.provide( Layer.mergeAll( Layer.mock(ThreadManagementService.ThreadManagementService)({ @@ -317,6 +326,100 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy }).pipe(Effect.provide(layer), Effect.runPromise); }); +it("lists created cross-project threads in sorted pages from one active snapshot", async () => { + const foreignProjectId = ProjectId.make("project-list-foreign"); + const shell = (id: string, project: ProjectId, minute: number) => ({ + ...baseThread({ + threadId: ThreadId.make(id), + title: id, + instanceId: parentInstanceId, + model: "gpt-5.4", + }), + projectId: project, + updatedAt: DateTime.makeUnsafe(`2026-08-04T12:0${minute}:00.000Z`), + status: "idle" as const, + activityRunStatus: null, + }); + const older = shell("older", projectId, 1); + const created = shell("created", foreignProjectId, 2); + const newer = shell("newer", projectId, 3); + const unrelated = shell("unrelated", foreignProjectId, 4); + const subagent = { + ...shell("subagent", foreignProjectId, 5), + lineage: { ...created.lineage, relationshipToParent: "subagent" as const }, + }; + const shells = [older, newer, unrelated, created, subagent]; + let snapshots = 0; + let shellReads = 0; + const layer = orchestratorMcpServiceLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => + Effect.succeed({ + thread: baseThread({ + threadId: parentThreadId, + title: "Parent", + instanceId: parentInstanceId, + model: "gpt-5.4", + }), + turnItems: [created, subagent].map((thread) => ({ + type: "thread_created", + targetThreadId: thread.id, + })), + } as unknown as OrchestrationV2ThreadProjection), + getShellSnapshot: () => + Effect.sync(() => { + snapshots += 1; + return { threads: shells } as never; + }), + getThreadShell: (threadId) => + Effect.sync(() => { + shellReads += 1; + return shells.find((thread) => thread.id === threadId) as never; + }), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({ + list: () => Effect.succeed({ tasks: [] }), + }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + NodeCrypto.layer, + ), + ), + ); + await Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const first = yield* service.listThreads(makeScope(), { limit: 2, includeSubagents: false }); + expect(first.threads.map((thread) => thread.threadId)).toEqual([newer.id, created.id]); + expect(first.nextCursor).toBe(2); + expect(first.total).toBe(3); + expect(first.projectId).toBe(projectId); + expect(first.threads.map((thread) => thread.projectId)).toEqual([projectId, foreignProjectId]); + expect(snapshots).toBe(1); + expect(shellReads).toBe(0); + const second = yield* service.listThreads(makeScope(), { + cursor: first.nextCursor!, + limit: 2, + includeSubagents: false, + }); + expect(second.threads.map((thread) => thread.threadId)).toEqual([older.id]); + expect(second.nextCursor).toBeNull(); + expect(second.total).toBe(3); + const withSubagents = yield* service.listThreads(makeScope(), {}); + expect(withSubagents.threads.map((thread) => thread.threadId)).toEqual([ + subagent.id, + newer.id, + created.id, + older.id, + ]); + expect(snapshots).toBe(3); + expect(shellReads).toBe(0); + }).pipe(Effect.provide(layer), Effect.runPromise); +}); + it("readThread reaches a thread the user attached as context, but not one an agent attached", async () => { const foreignProjectId = ProjectId.make("project-mcp-orchestrator-foreign"); const foreignThreadId = ThreadId.make("thread-mcp-orchestrator-foreign"); @@ -345,6 +448,9 @@ it("readThread reaches a thread the user attached as context, but not one an age createdAt: now, updatedAt: now, }); + let createdRecord = false; + let deleted = false; + let parentDeleted = false; const parentProjection = { thread: baseThread({ threadId: parentThreadId, @@ -353,6 +459,7 @@ it("readThread reaches a thread the user attached as context, but not one an age model: "gpt-5.4", }), runs: [], + turnItems: [], visibleTurnItems: [], runtimeRequests: [], messages: [ @@ -373,6 +480,7 @@ it("readThread reaches a thread the user attached as context, but not one an age model: "gpt-5.4", }), projectId: foreignProjectId, + deletedAt: deleted ? now : null, }, runs: [], visibleTurnItems: [ @@ -400,12 +508,46 @@ it("readThread reaches a thread the user attached as context, but not one an age updatedAt: now, }) as unknown as OrchestrationV2ThreadProjection; - const layer = OrchestratorMcpService.layer.pipe( + const layer = orchestratorMcpServiceLayer.pipe( Layer.provide( Layer.mergeAll( Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadRecords: (threadId) => { - if (threadId === parentThreadId) return Effect.succeed(parentProjection); + getThreadRecords: (threadId, _collections, options) => { + if (threadId === parentThreadId) { + expect(options).toEqual({ + turnItemTypes: ["thread_created"], + messageRoles: ["user"], + }); + return Effect.succeed({ + ...parentProjection, + thread: { ...parentProjection.thread, deletedAt: parentDeleted ? now : null }, + turnItems: createdRecord + ? [ + { + type: "thread_created" as const, + targetThreadId: agentOnlyThreadId, + id: TurnItemId.make("created-record"), + threadId: parentThreadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed" as const, + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + targetRunId: null, + targetProviderInstanceId: parentInstanceId, + targetModel: "gpt-5.4", + }, + ] + : [], + }); + } if (threadId === foreignThreadId || threadId === agentOnlyThreadId) { return Effect.succeed(foreignProjection(threadId)); } @@ -424,6 +566,29 @@ it("readThread reaches a thread the user attached as context, but not one an age threadId: input.threadId, }), ), + getShellSnapshot: () => + Effect.succeed({ + threads: [foreignThreadId, agentOnlyThreadId].map((threadId) => ({ + ...foreignProjection(threadId).thread, + status: "idle", + activityRunStatus: null, + })), + } as never), + getThreadShell: (threadId) => + Effect.succeed({ + ...foreignProjection(threadId).thread, + status: "idle", + activityRunStatus: null, + } as never), + // Access checks must reject before these succeed. + sendToThread: () => + Effect.succeed({ + run: { id: RunId.make("run-unexpected-send"), status: "running" }, + delivery: "started", + } as never), + waitForThread: (input) => + Effect.succeed({ threadId: input.threadId, run: null, timedOut: false }), + interruptThread: () => Effect.succeed({ type: "no_active_run" } as const), } satisfies Partial), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), @@ -454,5 +619,107 @@ it("readThread reaches a thread the user attached as context, but not one an age .sendToThread(makeScope(), { threadId: foreignThreadId, message: "hi" }) .pipe(Effect.flip); expect(write.code).toBe("thread_not_found"); + expect( + (yield* service.waitForThread(makeScope(), { threadId: foreignThreadId }).pipe(Effect.flip)) + .code, + ).toBe("thread_not_found"); + expect( + (yield* service.interruptThread(makeScope(), { threadId: foreignThreadId }).pipe(Effect.flip)) + .code, + ).toBe("thread_not_found"); + const unrelatedThreadId = ThreadId.make("thread-unrelated-cross-project"); + for (const operation of [ + service.readThread(makeScope(), { threadId: unrelatedThreadId }), + service.sendToThread(makeScope(), { threadId: unrelatedThreadId, message: "hi" }), + service.waitForThread(makeScope(), { threadId: unrelatedThreadId }), + service.interruptThread(makeScope(), { threadId: unrelatedThreadId }), + ]) { + expect( + (yield* Effect.flip(Effect.asVoid(operation))).code, + ).toBe("thread_not_found"); + } + createdRecord = true; + const listed = yield* service.listThreads(makeScope(), {}); + expect(listed.threads.map((thread) => thread.threadId)).toEqual([agentOnlyThreadId]); + expect(listed.total).toBe(1); + expect(listed.nextCursor).toBeNull(); + expect((yield* service.listThreads(makeScope(), { cursor: 1 })).threads).toEqual([]); + const unprivilegedScope = { ...makeScope(), capabilities: new Set() }; + for (const operation of [ + service.readThread(unprivilegedScope, { threadId: agentOnlyThreadId }), + service.sendToThread(unprivilegedScope, { threadId: agentOnlyThreadId, message: "hi" }), + service.waitForThread(unprivilegedScope, { threadId: agentOnlyThreadId }), + service.interruptThread(unprivilegedScope, { threadId: agentOnlyThreadId }), + service.createThreads(unprivilegedScope, { threads: [{ projectDirectory: "/known" }] }), + ]) { + expect( + (yield* Effect.flip(Effect.asVoid(operation))).code, + ).toBe("capability_denied"); + } + for (const operation of [ + service.readThread(makeScope(), { threadId: unrelatedThreadId }), + service.sendToThread(makeScope(), { threadId: unrelatedThreadId, message: "hi" }), + service.waitForThread(makeScope(), { threadId: unrelatedThreadId }), + service.interruptThread(makeScope(), { threadId: unrelatedThreadId }), + ]) { + expect( + (yield* Effect.flip(Effect.asVoid(operation))).code, + ).toBe("thread_not_found"); + } + expect( + (yield* service.readThread(makeScope(), { threadId: agentOnlyThreadId })).thread.threadId, + ).toBe(agentOnlyThreadId); + parentDeleted = true; + for (const operation of [ + service.listThreads(makeScope(), {}), + service.readThread(makeScope(), { threadId: agentOnlyThreadId }), + service.sendToThread(makeScope(), { threadId: agentOnlyThreadId, message: "hi" }), + service.waitForThread(makeScope(), { threadId: agentOnlyThreadId }), + service.interruptThread(makeScope(), { threadId: agentOnlyThreadId }), + ]) { + const result = yield* Effect.asVoid(operation).pipe( + Effect.match({ onFailure: (error) => error.code, onSuccess: () => "allowed" }), + ); + expect(result).toBe("thread_not_found"); + } + parentDeleted = false; + deleted = true; + expect( + (yield* service.readThread(makeScope(), { threadId: agentOnlyThreadId }).pipe(Effect.flip)) + .code, + ).toBe("thread_not_found"); + expect( + (yield* service + .sendToThread(makeScope(), { threadId: agentOnlyThreadId, message: "hi" }) + .pipe(Effect.flip)).code, + ).toBe("thread_not_found"); + expect( + (yield* service.waitForThread(makeScope(), { threadId: agentOnlyThreadId }).pipe(Effect.flip)) + .code, + ).toBe("thread_not_found"); + expect( + (yield* service + .interruptThread(makeScope(), { threadId: agentOnlyThreadId }) + .pipe(Effect.flip)).code, + ).toBe("thread_not_found"); + }).pipe(Effect.provide(layer), Effect.runPromise); + // Rebuild the service over persisted caller/target snapshots, retaining the grant row. + parentDeleted = true; + deleted = false; + await Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + for (const operation of [ + service.readThread(makeScope(), { threadId: agentOnlyThreadId }), + service.sendToThread(makeScope(), { threadId: agentOnlyThreadId, message: "after restart" }), + service.waitForThread(makeScope(), { threadId: agentOnlyThreadId }), + service.interruptThread(makeScope(), { threadId: agentOnlyThreadId }), + service.listThreads(makeScope(), {}), + ]) { + expect( + yield* Effect.asVoid(operation).pipe( + Effect.match({ onFailure: (error) => error.code, onSuccess: () => "allowed" }), + ), + ).toBe("thread_not_found"); + } }).pipe(Effect.provide(layer), Effect.runPromise); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 1ac257c10cb3..2c7b79e3b4c1 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -9,8 +9,13 @@ import { RunId, ThreadId, type OrchestrationV2ThreadProjection, + type OrchestrationV2ServerCommand, + type Project, type ServerProvider, } from "@t3tools/contracts"; +import * as NodeOS from "node:os"; +import * as Option from "effect/Option"; +import * as ProjectService from "../project/ProjectService.ts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; @@ -102,6 +107,7 @@ describe("OrchestratorMcpService", () => { list: () => Effect.succeed([]), }), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), ); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-ack"), @@ -179,6 +185,7 @@ describe("OrchestratorMcpService", () => { list: () => Effect.succeed([]), }), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), ); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-cancel"), @@ -247,6 +254,7 @@ describe("OrchestratorMcpService", () => { list: () => Effect.succeed([]), }), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), ); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-cancel-failed"), @@ -322,6 +330,7 @@ describe("OrchestratorMcpService", () => { list: () => Effect.succeed([]), }), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), ); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-cancel-dispose-failed"), @@ -346,6 +355,707 @@ describe("OrchestratorMcpService", () => { }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); }), ); + + it.effect("rejects a relative project directory before creating a top-level thread", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-project-relative-parent"); + const dispatched = yield* Ref.make>([]); + const parentProjection = { + thread: { + deletedAt: null, + id: parentThreadId, + projectId: ProjectId.make("project:parent"), + title: "Parent", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: "/workspace/parent", + }, + runs: [ + { + id: RunId.make("run:mcp-project-relative-parent"), + ordinal: 1, + status: "running", + rootNodeId: NodeId.make("node:mcp-project-relative-root"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], + subagents: [], + turnItems: [], + contextTransfers: [], + } as unknown as OrchestrationV2ThreadProjection; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.as({} as never), + ), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-17T00:00:00.000Z", + models: [{ slug: "gpt-5.4", name: "gpt-5.4", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + } as unknown as ServerProvider, + ]), + }), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: () => Effect.succeed(Option.none()), + }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([ProviderInstanceId.make("codex")]), + }), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-project-relative"), + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-project-relative", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const error = yield* service + .createThreads(scope, { + threads: [{ title: "Other workspace", projectDirectory: "other" }], + clientRequestId: "create-relative-project", + }) + .pipe(Effect.flip); + assert.equal(error.code, "invalid_request"); + assert.match(error.message, /absolute path/); + assert.deepEqual(yield* Ref.get(dispatched), []); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); + + it.effect("rejects a project directory T3 does not know", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-project-unknown-parent"); + const dispatched = yield* Ref.make>([]); + const parentProjection = { + thread: { + deletedAt: null, + id: parentThreadId, + projectId: ProjectId.make("project:parent"), + title: "Parent", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: "/workspace/parent", + }, + runs: [ + { + id: RunId.make("run:mcp-project-unknown-parent"), + ordinal: 1, + status: "running", + rootNodeId: NodeId.make("node:mcp-project-unknown-root"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], + subagents: [], + turnItems: [], + contextTransfers: [], + } as unknown as OrchestrationV2ThreadProjection; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.as({} as never), + ), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-17T00:00:00.000Z", + models: [{ slug: "gpt-5.4", name: "gpt-5.4", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + } as unknown as ServerProvider, + ]), + }), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: () => Effect.succeed(Option.none()), + }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([ProviderInstanceId.make("codex")]), + }), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-project-unknown"), + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-project-unknown", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const error = yield* service + .createThreads(scope, { + threads: [{ title: "Unknown workspace", projectDirectory: "/workspace/unknown" }], + clientRequestId: "create-unknown-project", + }) + .pipe(Effect.flip); + assert.equal(error.code, "invalid_request"); + assert.match(error.message, /not a known T3 project/); + assert.deepEqual(yield* Ref.get(dispatched), []); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); + + it.effect.each([false, true])( + "creates at the selected project root (same project: %s)", + (sameProject) => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-project-known-parent"); + const otherProjectId = ProjectId.make(sameProject ? "project:parent" : "project:other"); + const dispatched = yield* Ref.make>([]); + const parentProjection = { + thread: { + deletedAt: null, + id: parentThreadId, + projectId: ProjectId.make("project:parent"), + title: "Parent", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: "/workspace/parent", + }, + runs: [ + { + id: RunId.make("run:mcp-project-known-parent"), + ordinal: 1, + status: "running", + rootNodeId: NodeId.make("node:mcp-project-known-root"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], + subagents: [], + turnItems: [], + contextTransfers: [], + } as unknown as OrchestrationV2ThreadProjection; + const otherProject = { + id: otherProjectId, + title: "Other", + workspaceRoot: "/workspace/other", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + } as Project; + let deleted = false; + let parentDeleted = false; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? ({ + ...parentProjection, + thread: { + ...parentProjection.thread, + deletedAt: parentDeleted ? "2026-01-02T00:00:00.000Z" : null, + }, + } as unknown as OrchestrationV2ThreadProjection) + : ({ + thread: { + id: threadId, + projectId: otherProjectId, + title: "Other workspace", + deletedAt: null, + createdBy: "agent", + creationSource: "mcp", + worktreePath: "/workspace/other", + }, + runs: [], + } as unknown as OrchestrationV2ThreadProjection), + ), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.as({} as never), + ), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-17T00:00:00.000Z", + models: [{ slug: "gpt-5.4", name: "gpt-5.4", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + } as unknown as ServerProvider, + ]), + }), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([ProviderInstanceId.make("codex")]), + }), + Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (workspaceRoot) => + Effect.succeed( + workspaceRoot === "/workspace/other" + ? Option.some({ + ...otherProject, + deletedAt: deleted ? "2026-01-02T00:00:00.000Z" : null, + }) + : Option.none(), + ), + }), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-project-known"), + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-project-known", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const result = yield* service.createThreads(scope, { + threads: [{ title: "Other workspace", projectDirectory: "/workspace/other" }], + clientRequestId: "create-known-project", + }); + assert.equal(result.threads[0]?.title, "Other workspace"); + const command = (yield* Ref.get(dispatched)).find( + (entry) => entry.type === "thread.create", + ) as { + type: string; + projectId?: string; + worktreePath?: string | null; + branch?: string | null; + }; + assert.equal(command.type, "thread.create"); + assert.equal(command.projectId, otherProjectId); + assert.equal(command.worktreePath, null); + assert.equal(command.branch, null); + yield* service.createThreads(scope, { + threads: [{ title: "Inherited checkout" }], + clientRequestId: "inherited-checkout", + }); + const inheritedCommand = (yield* Ref.get(dispatched)).findLast( + (entry) => entry.type === "thread.create", + ); + assert.include(inheritedCommand, { + projectId: ProjectId.make("project:parent"), + branch: "main", + worktreePath: "/workspace/parent", + }); + const beforeDeletedRequest = yield* Ref.get(dispatched); + deleted = true; + const deletedResult = yield* service + .createThreads(scope, { + threads: [{ projectDirectory: "/workspace/other" }], + clientRequestId: "deleted-project", + }) + .pipe(Effect.flip); + assert.equal(deletedResult.code, "invalid_request"); + assert.deepEqual(yield* Ref.get(dispatched), beforeDeletedRequest); + parentDeleted = true; + const deletedParentResult = yield* service + .createThreads(scope, { + threads: [{ title: "Deleted parent" }], + clientRequestId: "deleted-parent", + }) + .pipe(Effect.flip); + assert.equal(deletedParentResult.code, "parent_not_active"); + assert.deepEqual(yield* Ref.get(dispatched), beforeDeletedRequest); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); + + it.effect("expands a home-relative project directory", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-project-home-parent"); + const otherProjectId = ProjectId.make("project:home-known"); + const homeKnown = `${NodeOS.homedir()}/known-project`; + const dispatched = yield* Ref.make>([]); + const parentProjection = { + thread: { + deletedAt: null, + id: parentThreadId, + projectId: ProjectId.make("project:parent"), + title: "Parent", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: "/workspace/parent", + }, + runs: [ + { + id: RunId.make("run:mcp-project-home-parent"), + ordinal: 1, + status: "running", + rootNodeId: NodeId.make("node:mcp-project-home-root"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], + subagents: [], + turnItems: [], + contextTransfers: [], + } as unknown as OrchestrationV2ThreadProjection; + const otherProject = { + id: otherProjectId, + title: "Home known", + workspaceRoot: homeKnown, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + } as Project; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? parentProjection + : ({ + thread: { + id: threadId, + projectId: otherProjectId, + title: "Home known", + deletedAt: null, + createdBy: "agent", + creationSource: "mcp", + worktreePath: null, + }, + runs: [], + } as unknown as OrchestrationV2ThreadProjection), + ), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command as { type: string }]).pipe( + Effect.as({} as never), + ), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-17T00:00:00.000Z", + models: [{ slug: "gpt-5.4", name: "gpt-5.4", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + } as unknown as ServerProvider, + ]), + }), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([ProviderInstanceId.make("codex")]), + }), + Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (workspaceRoot) => + Effect.succeed(workspaceRoot === homeKnown ? Option.some(otherProject) : Option.none()), + }), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-project-home"), + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-project-home", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + yield* service.createThreads(scope, { + threads: [{ title: "Home known", projectDirectory: "~/known-project" }], + clientRequestId: "create-home-project", + }); + const command = (yield* Ref.get(dispatched)).find( + (entry) => entry.type === "thread.create", + ) as { type: string; projectId?: string; worktreePath?: string | null }; + assert.equal(command.projectId, otherProjectId); + assert.equal(command.worktreePath, null); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); + + it.effect("reports project lookup faults as orchestration errors", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-project-fault-parent"); + const parentProjection = { + thread: { + deletedAt: null, + id: parentThreadId, + projectId: ProjectId.make("project:parent"), + title: "Parent", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: "/workspace/parent", + }, + runs: [ + { + id: RunId.make("run:mcp-project-fault-parent"), + ordinal: 1, + status: "running", + rootNodeId: NodeId.make("node:mcp-project-fault-root"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], + subagents: [], + turnItems: [], + contextTransfers: [], + } as unknown as OrchestrationV2ThreadProjection; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection), + dispatch: () => Effect.succeed({} as never), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-17T00:00:00.000Z", + models: [{ slug: "gpt-5.4", name: "gpt-5.4", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + } as unknown as ServerProvider, + ]), + }), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([ProviderInstanceId.make("codex")]), + }), + Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (workspaceRoot) => + Effect.fail( + new ProjectService.ProjectOperationError( + workspaceRoot === "/workspace/missing" + ? { + operation: "normalize-workspace", + cause: { _tag: "WorkspaceRootNotExistsError" }, + } + : { + operation: "list-projects", + cause: "simulated list failure", + }, + ), + ), + }), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-project-fault"), + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-project-fault", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const error = yield* service + .createThreads(scope, { + threads: [{ title: "Fault", projectDirectory: "/workspace/other" }], + clientRequestId: "create-fault-project", + }) + .pipe(Effect.flip); + assert.equal(error.code, "orchestration_error"); + assert.match(error.message, /Unable to resolve project directory/); + const unavailable = yield* service + .createThreads(scope, { + threads: [{ title: "Missing root", projectDirectory: "/workspace/missing" }], + clientRequestId: "create-missing-root", + }) + .pipe(Effect.flip); + assert.equal(unavailable.code, "invalid_request"); + assert.match(unavailable.message, /not a known T3 project/); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); + + it.effect( + "records a launched thread under the calling run with a fresh command per attempt", + () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-launch-grant-parent"); + const parentRunId = RunId.make("run:mcp-launch-grant-parent"); + const parentNodeId = NodeId.make("node:mcp-launch-grant-root"); + const launchedThreadId = ThreadId.make("thread:mcp-launch-grant-target"); + const deletedThreadId = ThreadId.make("thread:mcp-launch-grant-deleted"); + const launchedRunId = RunId.make("run:mcp-launch-grant-target"); + const dispatched = yield* Ref.make>([]); + let parentState: "active" | "idle" | "deleted" = "active"; + const parentProjection = () => + ({ + thread: { + id: parentThreadId, + projectId: ProjectId.make("project:parent"), + deletedAt: parentState === "deleted" ? "2026-01-02T00:00:00.000Z" : null, + }, + runs: [ + { + id: RunId.make("run:mcp-launch-grant-earlier"), + ordinal: 1, + status: "completed", + rootNodeId: NodeId.make("node:mcp-launch-grant-earlier"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ...(parentState === "idle" + ? [] + : [ + { + id: parentRunId, + ordinal: 2, + status: "running", + rootNodeId: parentNodeId, + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ]), + ], + subagents: [], + turnItems: [], + contextTransfers: [], + }) as unknown as OrchestrationV2ThreadProjection; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.sync(parentProjection), + getThreadShell: (threadId) => + Effect.succeed( + threadId === launchedThreadId || threadId === deletedThreadId + ? ({ + id: threadId, + projectId: ProjectId.make("project:other"), + deletedAt: threadId === deletedThreadId ? "2026-01-02T00:00:00.000Z" : null, + } as never) + : null, + ), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.as({} as never), + ), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-launch-grant"), + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-launch-grant", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const grant = yield* service.launchedThreadGrant(scope); + yield* grant(launchedThreadId, null); + yield* grant(launchedThreadId, launchedRunId); + const records = yield* Ref.get(dispatched); + assert.deepEqual( + records.map((command) => + command.type === "thread.created.record" + ? { + parentThreadId: command.parentThreadId, + parentRunId: command.parentRunId, + parentNodeId: command.parentNodeId, + targetThreadId: command.targetThreadId, + targetRunId: command.targetRunId, + } + : command.type, + ), + [ + { + parentThreadId, + parentRunId, + parentNodeId, + targetThreadId: launchedThreadId, + targetRunId: null, + }, + { + parentThreadId, + parentRunId, + parentNodeId, + targetThreadId: launchedThreadId, + targetRunId: launchedRunId, + }, + ], + ); + assert.notEqual(records[0]?.commandId, records[1]?.commandId); + + const outcome = Effect.match({ + onFailure: (error: { readonly code: string }) => error.code, + onSuccess: () => "granted", + }); + const targetOutcomes: Array = []; + for (const threadId of [deletedThreadId, ThreadId.make("thread:mcp-launch-missing")]) { + targetOutcomes.push(yield* grant(threadId, null).pipe(outcome)); + } + assert.deepEqual(targetOutcomes, ["thread_not_found", "thread_not_found"]); + assert.lengthOf(yield* Ref.get(dispatched), 2); + + const parentOutcomes: Array = []; + for (const state of ["idle", "deleted"] as const) { + parentState = state; + parentOutcomes.push(yield* service.launchedThreadGrant(scope).pipe(outcome)); + } + assert.deepEqual(parentOutcomes, ["parent_not_active", "parent_not_active"]); + assert.lengthOf(yield* Ref.get(dispatched), 2); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); }); describe("OrchestratorMcpService provider resolution", () => { @@ -416,6 +1126,7 @@ describe("OrchestratorMcpService provider resolution", () => { id: parentThreadId, projectId, title: "MCP parent", + deletedAt: null, createdBy: "user", creationSource: "web", modelSelection, @@ -514,6 +1225,7 @@ describe("OrchestratorMcpService provider resolution", () => { disabledAntigravityInstanceId, ]), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), ); yield* Effect.gen(function* () { @@ -637,6 +1349,7 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed(providers) }), adapterRegistryLayer([codexInstanceId, unauthenticatedInstanceId]), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), ); yield* Effect.gen(function* () { @@ -749,6 +1462,7 @@ describe("OrchestratorMcpService provider resolution", () => { }), adapterRegistryLayer([codexInstanceId, antigravityInstanceId]), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), ); yield* Effect.gen(function* () { @@ -842,6 +1556,7 @@ describe("OrchestratorMcpService provider resolution", () => { }), adapterRegistryLayer([codexInstanceId, antigravityInstanceId]), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), ); yield* Effect.gen(function* () { @@ -890,6 +1605,7 @@ describe("OrchestratorMcpService provider resolution", () => { }), adapterRegistryLayer([codexInstanceId]), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), ); yield* Effect.gen(function* () { @@ -1053,6 +1769,7 @@ describe("OrchestratorMcpService provider resolution", () => { }), adapterRegistryLayer([codexInstanceId, codexAltInstanceId]), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), ); yield* Effect.gen(function* () { diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 40c16bdc7e24..be204dd31697 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -53,6 +53,7 @@ import { type ServerProvider, ThreadId, } from "@t3tools/contracts"; +import * as NodeOS from "node:os"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -60,6 +61,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; @@ -68,6 +70,7 @@ import { delegatedTaskProgress, } from "../orchestration-v2/SubagentProjection.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; @@ -112,6 +115,14 @@ export interface OrchestratorMcpServiceShape { scope: McpInvocationScope, input: OrchestratorMcpCreateThreadsInput, ) => Effect.Effect; + /** + * Checks that the caller can own a launched thread and returns the recorder that + * grants it. Call the recorder once the thread exists, before its first message, + * and again with the first run once that run exists. + */ + readonly launchedThreadGrant: ( + scope: McpInvocationScope, + ) => Effect.Effect; readonly scheduleTask: ( scope: McpInvocationScope, input: OrchestratorMcpScheduleTaskInput, @@ -149,12 +160,28 @@ export interface OrchestratorMcpServiceShape { ) => Effect.Effect; } +/** + * Records in the caller's durable timeline that it created `threadId`. That record lets + * the caller reach the thread when it lives in another project. + */ +type OrchestratorMcpCreationGrant = ( + threadId: ThreadId, + targetRunId: RunId | null, +) => Effect.Effect; + +interface CreationParent { + readonly parent: Pick; + readonly parentRun: OrchestrationV2Run; + readonly parentNodeId: NodeId; +} + export class OrchestratorMcpService extends Context.Service< OrchestratorMcpService, OrchestratorMcpServiceShape >()("t3/mcp/OrchestratorMcpService") {} const isThreadManagementError = Schema.is(ThreadManagementService.ThreadManagementError); +const isProjectOperationError = Schema.is(ProjectService.ProjectOperationError); function failure(code: OrchestratorMcpFailure["code"], message: string): OrchestratorMcpFailure { return new OrchestratorMcpFailure({ code, message }); @@ -183,6 +210,50 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +interface ResolvedProjectCheckout { + readonly projectId: OrchestrationV2ThreadProjection["thread"]["projectId"]; + readonly branch: string | null; + readonly worktreePath: string | null; +} + +function expandedAbsoluteProjectDirectory(value: string, path: Path.Path): string | null { + const trimmed = value.trim(); + if (trimmed === "~") { + return path.resolve(NodeOS.homedir()); + } + if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) { + return path.resolve(path.join(NodeOS.homedir(), trimmed.slice(2))); + } + if (!path.isAbsolute(trimmed)) { + return null; + } + return path.resolve(trimmed); +} + +function unknownProjectDirectory(path: string): OrchestratorMcpFailure { + return failure("invalid_request", `Project directory '${path}' is not a known T3 project.`); +} + +function isMissingWorkspaceRoot(error: ProjectService.ProjectOperationError): boolean { + if (error.operation !== "normalize-workspace") { + return false; + } + const cause = error.cause; + if (cause === null || typeof cause !== "object" || !("_tag" in cause)) { + return false; + } + return ( + cause._tag === "WorkspaceRootNotExistsError" || cause._tag === "WorkspaceRootNotDirectoryError" + ); +} + +function projectDirectoryResolveFailure(absolute: string, error: unknown): OrchestratorMcpFailure { + if (isProjectOperationError(error) && !isMissingWorkspaceRoot(error)) { + return failure("orchestration_error", `Unable to resolve project directory '${absolute}'.`); + } + return unknownProjectDirectory(absolute); +} + /** * Workspace strategy for a scheduled task created/updated over MCP: bound runs * post into the existing thread (the strategy is unused, keep root); unbound @@ -609,6 +680,7 @@ function threadSettlement( function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpThreadListItem { return { threadId: shell.id, + projectId: shell.projectId, title: shell.title, createdBy: shell.createdBy, creationSource: shell.creationSource, @@ -793,6 +865,8 @@ function timelineItem(input: { const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; + const path = yield* Path.Path; + const projects = yield* ProjectService.ProjectService; const threadManagement = yield* ThreadManagementService.ThreadManagementService; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; @@ -808,7 +882,7 @@ const make = Effect.gen(function* () { ), ); - const loadProjection = (threadId: ThreadId) => + const loadProjection = (threadId: ThreadId, includeCreatedThreads = false) => threadManagement .getThreadRecords( threadId, @@ -822,7 +896,10 @@ const make = Effect.gen(function* () { "contextTransfers", "turnItems", ], - { turnItemTypes: [], messageRoles: ["user"] }, + { + turnItemTypes: includeCreatedThreads ? ["thread_created"] : [], + messageRoles: ["user"], + }, ) .pipe( Effect.mapError((error) => @@ -854,14 +931,34 @@ const make = Effect.gen(function* () { ) .pipe(Effect.mapError(threadManagementFailure)); + const loadLiveParent = Effect.fnUntraced(function* (scope: McpInvocationScope) { + const parent = yield* loadProjection(scope.threadId, true); + if (parent.thread.deletedAt !== null) { + return yield* failure("thread_not_found", `Thread ${scope.threadId} is no longer available.`); + } + return parent; + }); + const loadScopedThread = (scope: McpInvocationScope, threadId: ThreadId) => Effect.gen(function* () { yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); + const parent = yield* loadLiveParent(scope); const target = threadId === scope.threadId ? parent - : yield* loadProjectThread(parent.thread.projectId, threadId); + : yield* loadProjectThread(parent.thread.projectId, threadId).pipe( + Effect.catchIf( + (error) => + error.code === "thread_not_found" && + parent.turnItems.some( + (item) => item.type === "thread_created" && item.targetThreadId === threadId, + ), + () => loadProjection(threadId), + ), + ); + if (target.thread.deletedAt !== null) { + return yield* failure("thread_not_found", `Thread ${threadId} is no longer available.`); + } return { parent, target } as const; }); @@ -886,7 +983,7 @@ const make = Effect.gen(function* () { const loadReadableThread = (scope: McpInvocationScope, threadId: ThreadId) => Effect.gen(function* () { yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); + const parent = yield* loadLiveParent(scope); const loadTarget = () => threadManagement .getThreadRecords(threadId, ["runs", "runtimeRequests", "contextTransfers"]) @@ -902,7 +999,11 @@ const make = Effect.gen(function* () { Effect.mapError(threadManagementFailure), Effect.catchIf( (error) => - error.code === "thread_not_found" && userAttachedThreadIds(parent).has(threadId), + error.code === "thread_not_found" && + (userAttachedThreadIds(parent).has(threadId) || + parent.turnItems.some( + (item) => item.type === "thread_created" && item.targetThreadId === threadId, + )), loadTarget, ), ); @@ -912,6 +1013,94 @@ const make = Effect.gen(function* () { return { parent, target } as const; }); + const resolveProjectDirectory = Effect.fnUntraced(function* ( + parent: Pick, + projectDirectory: string | undefined, + ): Effect.fn.Return { + if (projectDirectory === undefined) { + return { + projectId: parent.thread.projectId, + branch: parent.thread.branch, + worktreePath: parent.thread.worktreePath, + }; + } + const absolute = expandedAbsoluteProjectDirectory(projectDirectory, path); + if (absolute === null) { + return yield* failure( + "invalid_request", + "projectDirectory must be an absolute path, or a home-relative ~/ path, to a known T3 project.", + ); + } + const project = yield* projects + .getByWorkspaceRoot(absolute) + .pipe(Effect.mapError((error) => projectDirectoryResolveFailure(absolute, error))); + if (Option.isNone(project) || project.value.deletedAt !== null) + return yield* unknownProjectDirectory(absolute); + return { projectId: project.value.id, branch: null, worktreePath: null }; + }); + + const loadCreationParent = Effect.fnUntraced(function* (scope: McpInvocationScope) { + yield* requireCapability(scope); + const parent = yield* loadProjection(scope.threadId, true); + const parentRun = ThreadManagementService.latestActiveRun(parent); + if ( + parent.thread.deletedAt !== null || + parentRun === undefined || + parentRun.rootNodeId === null || + parentRun.providerInstanceId !== scope.providerInstanceId + ) { + return yield* failure( + "parent_not_active", + "Thread creation requires an active run owned by this MCP provider session.", + ); + } + return { parent, parentRun, parentNodeId: parentRun.rootNodeId }; + }); + + const recordCreation = ( + scope: McpInvocationScope, + { parent, parentRun, parentNodeId }: CreationParent, + label: string, + index?: number, + ): OrchestratorMcpCreationGrant => + Effect.fnUntraced(function* (threadId, targetRunId) { + if ( + parent.turnItems.some( + (item) => + item.type === "thread_created" && + item.targetThreadId === threadId && + (targetRunId === null || item.targetRunId !== null), + ) + ) + return; + // Creation and prompt IDs stay stable. Only grant attempts get a fresh + // command ID, because rejected command receipts are permanent. + const recordAttempt = yield* requestKey(undefined); + yield* threadManagement + .dispatch({ + type: "thread.created.record", + commandId: stableCommandId({ + scope, + requestKey: recordAttempt, + operation: "record-created-thread", + ...(index === undefined ? {} : { index }), + }), + parentThreadId: scope.threadId, + parentRunId: parentRun.id, + parentNodeId, + targetThreadId: threadId, + targetRunId, + }) + .pipe( + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to record ${label} in the parent timeline: ${errorMessage(error)}`, + ), + ), + ); + }); + const loadProviders = providerRegistry.getProviders; /** @@ -1642,20 +1831,8 @@ const make = Effect.gen(function* () { }), createThreads: (scope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); - const parentRun = ThreadManagementService.latestActiveRun(parent); - if ( - parentRun === undefined || - parentRun.rootNodeId === null || - parentRun.providerInstanceId !== scope.providerInstanceId - ) { - return yield* failure( - "parent_not_active", - "Thread creation requires an active run owned by this MCP provider session.", - ); - } - const parentNodeId = parentRun.rootNodeId; + const creator = yield* loadCreationParent(scope); + const { parent } = creator; const providers = yield* loadProviders; const key = yield* requestKey(input.clientRequestId); const created = yield* Effect.forEach( @@ -1675,6 +1852,7 @@ const make = Effect.gen(function* () { parent.thread.interactionMode, request.interactionMode, ); + const checkout = yield* resolveProjectDirectory(parent, request.projectDirectory); const threadId = stableThreadId({ scope, requestKey: key, @@ -1698,13 +1876,13 @@ const make = Effect.gen(function* () { index, }), threadId, - projectId: parent.thread.projectId, + projectId: checkout.projectId, title, modelSelection: target.modelSelection, runtimeMode, interactionMode, - branch: parent.thread.branch, - worktreePath: parent.thread.worktreePath, + branch: checkout.branch, + worktreePath: checkout.worktreePath, }) .pipe( Effect.mapError((error) => @@ -1714,6 +1892,20 @@ const make = Effect.gen(function* () { ), ), ); + const createdProjection = yield* loadProjection(threadId); + if (createdProjection.thread.deletedAt !== null) { + return yield* failure( + "thread_not_found", + `Thread ${threadId} is no longer available.`, + ); + } + const recordCreatedThread = recordCreation( + scope, + creator, + `thread ${index + 1}`, + index, + ); + yield* recordCreatedThread(threadId, createdProjection.runs.at(-1)?.id ?? null); if (request.prompt !== undefined) { yield* threadManagement .dispatch({ @@ -1749,29 +1941,7 @@ const make = Effect.gen(function* () { } const projection = yield* loadProjection(threadId); const run = projection.runs.at(-1); - yield* threadManagement - .dispatch({ - type: "thread.created.record", - commandId: stableCommandId({ - scope, - requestKey: key, - operation: "record-created-thread", - index, - }), - parentThreadId: scope.threadId, - parentRunId: parentRun.id, - parentNodeId, - targetThreadId: threadId, - targetRunId: run?.id ?? null, - }) - .pipe( - Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to record thread ${index + 1} in the parent timeline: ${errorMessage(error)}`, - ), - ), - ); + if (run !== undefined) yield* recordCreatedThread(threadId, run.id); return { threadId, runId: run?.id ?? null, @@ -1787,23 +1957,67 @@ const make = Effect.gen(function* () { ); return { threads: created }; }), + launchedThreadGrant: (scope) => + Effect.gen(function* () { + const record = recordCreation( + scope, + yield* loadCreationParent(scope), + "the launched thread", + ); + return (threadId, targetRunId) => + Effect.gen(function* () { + const target = yield* threadManagement + .getThreadShell(threadId) + .pipe( + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to read thread ${threadId}: ${errorMessage(error)}`, + ), + ), + ); + if (target === null || target.deletedAt !== null) { + return yield* failure( + "thread_not_found", + `Thread ${threadId} is no longer available.`, + ); + } + yield* record(threadId, targetRunId); + }); + }), listThreads: (scope, input) => Effect.gen(function* () { yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); - const projectThreads = yield* threadManagement - .listProjectThreads({ - projectId: parent.thread.projectId, - includeSubagents: input.includeSubagents !== false, - }) + const parent = yield* loadLiveParent(scope); + const snapshot = yield* threadManagement + .getShellSnapshot() .pipe( Effect.mapError((error) => failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`), ), ); + const createdIds = new Set( + parent.turnItems.flatMap((item) => + item.type === "thread_created" ? [item.targetThreadId] : [], + ), + ); const statuses = input.statuses === undefined ? null : new Set(input.statuses); const titleContains = input.titleContains?.toLocaleLowerCase(); - const filtered = projectThreads + const filtered = snapshot.threads + .filter( + (thread) => thread.projectId === parent.thread.projectId || createdIds.has(thread.id), + ) + .filter( + (thread) => + input.includeSubagents !== false || + thread.lineage.relationshipToParent !== "subagent", + ) + .filter((thread) => thread.deletedAt === null) + .sort( + (left, right) => + DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) || + right.id.localeCompare(left.id), + ) .filter( (thread) => statuses === null || statuses.has(thread.activityRunStatus ?? thread.status), @@ -1925,7 +2139,7 @@ const make = Effect.gen(function* () { }); const result = yield* threadManagement .sendToThread({ - projectId: parent.thread.projectId, + projectId: target.thread.projectId, commandId: stableCommandId({ scope, requestKey: key, @@ -1960,10 +2174,10 @@ const make = Effect.gen(function* () { }), waitForThread: (scope, input) => Effect.gen(function* () { - const { parent } = yield* loadScopedThread(scope, input.threadId); + const { target } = yield* loadScopedThread(scope, input.threadId); const result = yield* threadManagement .waitForThread({ - projectId: parent.thread.projectId, + projectId: target.thread.projectId, threadId: input.threadId, ...(input.runId === undefined ? {} : { runId: input.runId }), timeoutMs: Math.min( @@ -1981,11 +2195,11 @@ const make = Effect.gen(function* () { }), interruptThread: (scope, input) => Effect.gen(function* () { - const { parent } = yield* loadScopedThread(scope, input.threadId); + const { target } = yield* loadScopedThread(scope, input.threadId); const key = yield* requestKey(input.clientRequestId); const result = yield* threadManagement .interruptThread({ - projectId: parent.thread.projectId, + projectId: target.thread.projectId, commandId: stableCommandId({ scope, requestKey: key, @@ -2028,5 +2242,7 @@ export const layer: Layer.Layer< | ThreadManagementService.ThreadManagementService | ProviderRegistry.ProviderRegistry | ProviderAdapterRegistry.ProviderAdapterRegistryV2 + | Path.Path + | ProjectService.ProjectService | ScheduledTaskService.ScheduledTaskService > = Layer.effect(OrchestratorMcpService, make); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 28dbb7de5f61..969268c82469 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1,4 +1,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { vi } from "vite-plus/test"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { + ProjectionStoreV2, + ProjectionStoreReadError, + layer as projectionStoreLayer, +} from "../orchestration-v2/ProjectionStore.ts"; +import { + CommandReceiptStoreV2, + layer as commandReceiptStoreLayer, +} from "../orchestration-v2/CommandReceiptStore.ts"; import { describe, expect, it } from "@effect/vitest"; import { CommandId, @@ -41,6 +52,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; @@ -72,6 +84,17 @@ import { materializeReplayTranscriptWorkspace, } from "../orchestration-v2/testkit/ReplayTranscriptNdjson.ts"; import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; +import * as ServerConfig from "../config.ts"; +import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import * as IdAllocator from "../orchestration-v2/IdAllocator.ts"; +import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; +import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as WorktreeSetupTracker from "../project/WorktreeSetupTracker.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -79,6 +102,18 @@ import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService. const parentThreadId = ThreadId.make("thread:mcp-orchestrator-parent"); const projectId = ProjectId.make("project:mcp-orchestrator"); +const otherProjectId = ProjectId.make("project:mcp-other"); +const otherProjectDirectory = "/workspace/mcp-other"; +const otherProject = { + id: otherProjectId, + title: "Other", + workspaceRoot: otherProjectDirectory, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +} as const; const codexInstanceId = ProviderInstanceId.make("codex"); const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); const codexModel = "gpt-5.4"; @@ -481,9 +516,9 @@ const unusedScheduledTaskStubLayer = Layer.succeed( ); describe("orchestrator MCP toolkit", () => { - it.live( - "delegates cross-provider tasks, polls and cancels children, and creates ordinary threads", - () => + it.live.each(["grant", "run-link", "run-link-exhausted"] as const)( + "delegates cross-provider tasks, polls and cancels children, and repairs ordinary thread creation (%s)", + (faultStage) => Effect.scoped( Effect.gen(function* () { const cwd = yield* checkpointWorkspace("orchestrator-mcp-toolkit"); @@ -640,15 +675,60 @@ describe("orchestrator MCP toolkit", () => { runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"), }), ); - const testLayer = Layer.merge( + // Root launches only resolve the project and release the prepared run. + const threadLaunchLayer = ThreadLaunch.layer.pipe( + Layer.provide( + Layer.mergeAll( + WorktreeSetupTracker.layer, + Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({ + get: () => Effect.succeed(null), + }), + Layer.mock(TerminalManager.TerminalManager)({}), + Layer.mock(GitWorkflow.GitWorkflowService)({}), + Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, { + runForThread: () => Effect.succeed({ status: "no-script" as const }), + }), + Layer.mock(TextGeneration.TextGeneration)({}), + ServerSettings.layerTest(), + IdAllocator.layer, + ), + ), + ); + const testLayer = Layer.mergeAll( McpHttpServer.OrchestratorToolkitRegistrationLive, McpHttpServer.ThreadToolkitRegistrationLive, + McpHttpServer.ProjectRegistrationLive, ).pipe( Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(threadLaunchLayer), Layer.provideMerge(orchestrationLayer), + Layer.provideMerge( + Layer.merge(projectionStoreLayer, commandReceiptStoreLayer).pipe( + Layer.provide(SqlitePersistenceMemory), + ), + ), Layer.provide(registryLayer), Layer.provide(providerRegistryLayer), Layer.provide(scheduledTaskStubLayer), + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (workspaceRoot) => + Effect.succeed( + workspaceRoot === otherProjectDirectory + ? Option.some(otherProject) + : Option.none(), + ), + getById: (id) => + Effect.succeed( + id === otherProjectId + ? Option.some(otherProject) + : id === projectId + ? Option.some({ ...otherProject, id: projectId, workspaceRoot: cwd }) + : Option.none(), + ), + }), + ), + Layer.provide(ServerConfig.layerTest(cwd, { prefix: "t3-mcp-orchestrator-toolkit-" })), Layer.provide(NodeServices.layer), ); @@ -1855,6 +1935,616 @@ describe("orchestrator MCP toolkit", () => { message: expect.stringContaining("more than once"), }); + const unknownProjectCall = yield* invoke("create_threads", { + threads: [ + { prompt: createdThreadPrompt, projectDirectory: "/definitely-not-a-t3-project" }, + ], + clientRequestId: "start-unknown-project-1", + }); + expect(unknownProjectCall.structuredContent).toMatchObject({ + _tag: "OrchestratorMcpFailure", + code: "invalid_request", + message: expect.stringContaining("not a known T3 project"), + }); + + const projectionStore = yield* ProjectionStoreV2; + const receiptStore = yield* CommandReceiptStoreV2; + const eventSink = yield* EventSink.EventSinkV2; + const originalRead = projectionStore.getThreadRecords; + let rejectRecord = true; + let recordReads = 0; + const readFault = vi + .spyOn(projectionStore, "getThreadRecords") + .mockImplementation((threadId, subsets, options) => { + if ( + threadId === parentThreadId && + subsets.join(",") === "runs,nodes,turnItems,attempts,providerTurns" + ) { + recordReads += 1; + if (rejectRecord && recordReads === (faultStage === "grant" ? 1 : 2)) { + rejectRecord = false; + return Effect.fail( + new ProjectionStoreReadError({ + threadId, + cause: "injected creation-record read failure", + }), + ); + } + } + return originalRead(threadId, subsets, options); + }); + const rejectedWrites = vi.spyOn(eventSink, "commitRejectedCommand"); + const repairRequest = { + threads: [{ prompt: createdThreadPrompt, projectDirectory: otherProjectDirectory }], + clientRequestId: "known-project-record-repair", + }; + const rejectedCreate = yield* invoke("create_threads", repairRequest).pipe( + Effect.ensuring(Effect.sync(() => readFault.mockRestore())), + ); + expect(rejectedCreate.structuredContent).toMatchObject({ + _tag: "OrchestratorMcpFailure", + code: "orchestration_error", + }); + expect(rejectRecord).toBe(false); + const rejectedWrite = rejectedWrites.mock.calls.find( + ([input]) => input.commandType === "thread.created.record", + )?.[0]; + expect(rejectedWrite).toBeDefined(); + const rejectedReceipt = yield* receiptStore.getByCommandId(rejectedWrite!.commandId); + expect(Option.isSome(rejectedReceipt) && rejectedReceipt.value.status).toBe("rejected"); + rejectedWrites.mockRestore(); + const failedTargets = (yield* orchestrator.getShellSnapshot()).threads.filter( + (thread) => thread.projectId === otherProjectId, + ); + expect(failedTargets).toHaveLength(1); + const repairTargetId = failedTargets[0]!.id; + expect((yield* orchestrator.getThreadProjection(repairTargetId)).runs).toHaveLength( + faultStage === "grant" ? 0 : 1, + ); + const beforeRepair = yield* orchestrator.getThreadProjection(parentThreadId); + expect( + beforeRepair.turnItems.some( + (item) => item.type === "thread_created" && item.targetThreadId === repairTargetId, + ), + ).toBe(faultStage !== "grant"); + const repairResults = yield* Effect.all( + [invoke("create_threads", repairRequest), invoke("create_threads", repairRequest)], + { concurrency: 2 }, + ); + for (const result of repairResults) { + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ + threads: [{ threadId: repairTargetId }], + }); + expect( + (yield* decodeCreateThreadsResult(result.structuredContent)).threads[0]?.threadId, + ).toBe(repairTargetId); + } + const repeatedRepair = yield* invoke("create_threads", repairRequest); + expect(repeatedRepair.isError).toBe(false); + const repairedTarget = yield* orchestrator.getThreadProjection(repairTargetId); + expect(repairedTarget.runs).toHaveLength(1); + expect( + repairedTarget.messages.filter((message) => message.role === "user"), + ).toHaveLength(1); + expect( + (yield* orchestrator.getThreadProjection(parentThreadId)).turnItems.filter( + (item) => item.type === "thread_created" && item.targetThreadId === repairTargetId, + ), + ).toHaveLength(1); + const repairedItem = (yield* orchestrator.getThreadProjection( + parentThreadId, + )).turnItems.find( + (item) => item.type === "thread_created" && item.targetThreadId === repairTargetId, + ); + expect(repairedItem).toMatchObject({ targetRunId: repairedTarget.runs[0]!.id }); + if (faultStage !== "grant") { + const initialItem = beforeRepair.turnItems.find( + (item) => item.type === "thread_created" && item.targetThreadId === repairTargetId, + )!; + expect(repairedItem).toMatchObject({ + id: initialItem.id, + ordinal: initialItem.ordinal, + startedAt: initialItem.startedAt, + }); + } + const repairedRead = yield* invoke("t3_thread_read", { threadId: repairTargetId }); + expect(repairedRead.structuredContent).toHaveProperty( + "thread.threadId", + repairTargetId, + ); + expect( + Option.getOrThrow(yield* receiptStore.getByCommandId(rejectedWrite!.commandId)) + .status, + ).toBe("rejected"); + const repairedWait = yield* invoke("t3_thread_wait", { + threadId: repairTargetId, + timeoutMs: 5000, + }); + expect(repairedWait.structuredContent).toMatchObject({ status: "completed" }); + expect( + (yield* Ref.get(capturedTurns)).filter((turn) => turn.threadId === repairTargetId), + ).toHaveLength(1); + const missingParentRepair = yield* invokeAs( + { ...invocation, threadId: ThreadId.make("missing-creation-parent") }, + "create_threads", + repairRequest, + ); + expect(missingParentRepair.structuredContent).toMatchObject({ + _tag: "OrchestratorMcpFailure", + }); + const threadManagement = yield* ThreadManagementService.ThreadManagementService; + yield* threadManagement.dispatch({ + type: "thread.delete", + threadId: repairTargetId, + commandId: CommandId.make("delete-repair-target"), + }); + const deletedRecord = yield* threadManagement + .dispatch({ + type: "thread.created.record", + commandId: CommandId.make("record-deleted-repair-target"), + parentThreadId, + parentRunId: repairedItem!.runId!, + parentNodeId: repairedItem!.nodeId!, + targetThreadId: repairTargetId, + targetRunId: null, + }) + .pipe(Effect.flip); + expect(deletedRecord).toMatchObject({ + _tag: "OrchestratorDispatchError", + commandType: "thread.created.record", + cause: "Cannot record creation for a deleted parent or target thread.", + }); + const deletedRepair = yield* invoke("create_threads", repairRequest); + expect(deletedRepair.structuredContent).toMatchObject({ + _tag: "OrchestratorMcpFailure", + code: "thread_not_found", + }); + expect((yield* orchestrator.getThreadProjection(repairTargetId)).runs).toHaveLength(1); + const deletedRead = yield* invoke("t3_thread_read", { threadId: repairTargetId }); + expect(deletedRead.structuredContent).toMatchObject({ + _tag: "OrchestratorMcpFailure", + code: "thread_not_found", + }); + + const knownProjectCall = yield* invoke("create_threads", { + threads: [{ prompt: createdThreadPrompt, projectDirectory: otherProjectDirectory }], + clientRequestId: "start-known-project-1", + }); + expect(knownProjectCall.isError).toBe(false); + const knownProjectThread = (yield* decodeCreateThreadsResult( + knownProjectCall.structuredContent, + ).pipe(Effect.orDie)).threads[0]!; + const knownProjectChild = yield* orchestrator.getThreadProjection( + knownProjectThread.threadId, + ); + expect(knownProjectChild.thread.projectId).toBe(otherProjectId); + expect(knownProjectChild.thread.worktreePath).toBeNull(); + expect(knownProjectChild.thread.branch).toBeNull(); + expect( + knownProjectChild.messages.find((message) => message.role === "user"), + ).toMatchObject({ + senderThreadId: parentThreadId, + }); + const unrelatedProjectThreadId = ThreadId.make("thread:unrelated-known-project"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("command:create-unrelated-known-project"), + threadId: unrelatedProjectThreadId, + projectId: otherProjectId, + title: "Not created by this caller", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + modelSelection: codexSelection, + runtimeMode: "full-access", + interactionMode: "default", + }); + for (const tool of [ + "t3_thread_read", + "t3_thread_send", + "t3_thread_wait", + "t3_thread_interrupt", + ] as const) { + const denied = yield* invoke(tool, { + threadId: unrelatedProjectThreadId, + ...(tool === "t3_thread_send" ? { message: "not authorized" } : {}), + }); + expect(denied.structuredContent).toMatchObject({ code: "thread_not_found" }); + } + const projectListing = yield* invoke("t3_thread_list", {}); + const projectListed = yield* decodeThreadListResult(projectListing.structuredContent); + expect( + projectListed.threads.some( + (thread) => thread.threadId === knownProjectThread.threadId, + ), + ).toBe(true); + expect( + projectListed.threads.some((thread) => thread.threadId === unrelatedProjectThreadId), + ).toBe(false); + + const knownReadCall = yield* invoke("t3_thread_read", { + threadId: knownProjectThread.threadId, + view: "messages", + }); + expect(knownReadCall.isError).toBe(false); + const knownRead = yield* decodeThreadReadResult(knownReadCall.structuredContent).pipe( + Effect.orDie, + ); + expect(knownRead.thread.projectId).toBe(otherProjectId); + expect(knownRead.thread.worktreePath).toBeNull(); + + const knownWaitCall = yield* invoke("t3_thread_wait", { + threadId: knownProjectThread.threadId, + }); + expect(knownWaitCall.isError).toBe(false); + expect(knownWaitCall.structuredContent).toMatchObject({ + threadId: knownProjectThread.threadId, + }); + + const knownSendCall = yield* invoke("t3_thread_send", { + threadId: knownProjectThread.threadId, + message: "Confirm the other workspace.", + mode: "queue", + clientRequestId: "send-known-project-1", + }); + expect(knownSendCall.isError).toBe(false); + const afterCrossProjectSend = yield* orchestrator.getThreadProjection( + knownProjectThread.threadId, + ); + expect( + afterCrossProjectSend.messages.find( + (message) => message.text === "Confirm the other workspace.", + )?.senderThreadId, + ).toBe(parentThreadId); + + const knownInterruptCall = yield* invoke("t3_thread_interrupt", { + threadId: knownProjectThread.threadId, + clientRequestId: "interrupt-known-project-1", + }); + expect(knownInterruptCall.isError).toBe(false); + expect(knownInterruptCall.structuredContent).toMatchObject({ + threadId: knownProjectThread.threadId, + }); + + // t3_thread_launch records the same caller grant as create_threads, before the + // launched thread's first message. + const seenThreadIds = new Set( + (yield* orchestrator.getShellSnapshot()).threads.map((thread) => thread.id), + ); + const newOtherProjectThreads = Effect.gen(function* () { + const shells = (yield* orchestrator.getShellSnapshot()).threads.filter( + (thread) => thread.projectId === otherProjectId && !seenThreadIds.has(thread.id), + ); + for (const shell of shells) seenThreadIds.add(shell.id); + return shells; + }); + const launchGrant = (threadId: ThreadId) => + orchestrator + .getThreadProjection(parentThreadId) + .pipe( + Effect.map((projection) => + projection.turnItems.find( + (item) => item.type === "thread_created" && item.targetThreadId === threadId, + ), + ), + ); + const unregisteredLaunch = yield* invoke("t3_thread_launch", { + projectId: "project:mcp-unregistered", + title: "Unregistered project launch", + message: createdThreadPrompt, + }); + expect(unregisteredLaunch.structuredContent).toMatchObject({ + _tag: "OrchestratorMcpFailure", + }); + expect( + (yield* orchestrator.getShellSnapshot()).threads.some( + (thread) => !seenThreadIds.has(thread.id), + ), + ).toBe(false); + + let rejectLaunchRecord = true; + let launchRecordReads = 0; + const linkAttemptTimes: number[] = []; + const launchReadFault = vi + .spyOn(projectionStore, "getThreadRecords") + .mockImplementation((threadId, subsets, options) => { + if ( + threadId === parentThreadId && + subsets.join(",") === "runs,nodes,turnItems,attempts,providerTurns" + ) { + launchRecordReads += 1; + if (launchRecordReads > 1) linkAttemptTimes.push(performance.now()); + const failAttempt = + faultStage === "grant" + ? launchRecordReads === 1 + : launchRecordReads > 1 && + (faultStage === "run-link-exhausted" || launchRecordReads < 5); + if (failAttempt) { + rejectLaunchRecord = false; + return Effect.fail( + new ProjectionStoreReadError({ + threadId, + cause: "injected launch-record read failure", + }), + ); + } + } + return originalRead(threadId, subsets, options); + }); + const launchRejectedWrites = vi.spyOn(eventSink, "commitRejectedCommand"); + const linkWarnings: unknown[] = []; + const linkLogger = Logger.make(({ logLevel, message }) => { + if (logLevel === "Warn") linkWarnings.push(message); + }); + const faultedLaunch = yield* invoke("t3_thread_launch", { + projectId: otherProjectId, + title: "Faulted launch", + message: createdThreadPrompt, + }).pipe( + Effect.provide(Logger.layer([linkLogger], { mergeWithExisting: false })), + Effect.ensuring(Effect.sync(() => launchReadFault.mockRestore())), + ); + if (faultStage === "grant") { + expect(faultedLaunch.structuredContent).toMatchObject({ + _tag: "OrchestratorMcpFailure", + code: "orchestration_error", + message: expect.stringContaining("Unable to record the launched thread"), + }); + } else { + expect(faultedLaunch.structuredContent).toMatchObject({ + projectId: otherProjectId, + runId: expect.any(String), + }); + } + expect(rejectLaunchRecord).toBe(false); + expect(launchRecordReads).toBe(faultStage === "grant" ? 1 : 5); + for (let index = 1; index < linkAttemptTimes.length; index += 1) { + expect( + linkAttemptTimes[index]! - linkAttemptTimes[index - 1]!, + ).toBeGreaterThanOrEqual(20 * 2 ** (index - 1)); + } + const launchRejectedWrite = launchRejectedWrites.mock.calls.find( + ([input]) => input.commandType === "thread.created.record", + )?.[0]; + launchRejectedWrites.mockRestore(); + expect(launchRejectedWrite).toBeDefined(); + const faultedTargets = yield* newOtherProjectThreads; + expect(faultedTargets).toHaveLength(1); + const faultedTargetId = faultedTargets[0]!.id; + expect(linkWarnings).toEqual( + faultStage === "run-link-exhausted" + ? [ + [ + "orchestrator-mcp.thread-launch.run-link-failed", + { + threadId: faultedTargetId, + runId: expect.any(String), + code: "orchestration_error", + }, + ], + ] + : [], + ); + // Grant refusal precedes dispatch; run-link retry must not dispatch again. + const faultedTarget = yield* orchestrator.getThreadProjection(faultedTargetId); + expect( + faultedTarget.messages.filter((message) => message.role === "user"), + ).toHaveLength(faultStage === "grant" ? 0 : 1); + const faultedGrant = yield* launchGrant(faultedTargetId); + const faultedRead = yield* invoke("t3_thread_read", { threadId: faultedTargetId }); + if (faultStage === "grant") { + expect(faultedTarget.runs).toHaveLength(0); + expect(faultedGrant).toBeUndefined(); + expect(faultedRead.structuredContent).toMatchObject({ code: "thread_not_found" }); + } else { + expect(faultedTarget.runs).toHaveLength(1); + expect(faultedLaunch.structuredContent).toMatchObject({ + threadId: faultedTargetId, + runId: faultedTarget.runs[0]!.id, + status: expect.any(String), + }); + expect(faultedGrant).toMatchObject({ + targetRunId: faultStage === "run-link-exhausted" ? null : faultedTarget.runs[0]!.id, + }); + const faultedListing = yield* decodeThreadListResult( + (yield* invoke("t3_thread_list", { titleContains: "Faulted launch" })) + .structuredContent, + ); + expect(faultedListing.threads.map((thread) => thread.threadId)).toEqual([ + faultedTargetId, + ]); + expect(faultedRead.structuredContent).toHaveProperty( + "thread.threadId", + faultedTargetId, + ); + const faultedWait = yield* invoke("t3_thread_wait", { + threadId: faultedTargetId, + timeoutMs: 5000, + }); + expect(faultedWait.structuredContent).toMatchObject({ + threadId: faultedTargetId, + status: "completed", + }); + const faultedSend = yield* invoke("t3_thread_send", { + threadId: faultedTargetId, + message: "Confirm access after link failure.", + mode: "queue", + clientRequestId: "send-faulted-launch", + }); + expect(faultedSend.structuredContent).toMatchObject({ threadId: faultedTargetId }); + const faultedInterrupt = yield* invoke("t3_thread_interrupt", { + threadId: faultedTargetId, + clientRequestId: "interrupt-faulted-launch", + }); + expect(faultedInterrupt.structuredContent).toMatchObject({ + threadId: faultedTargetId, + }); + } + expect( + Option.getOrThrow(yield* receiptStore.getByCommandId(launchRejectedWrite!.commandId)) + .status, + ).toBe("rejected"); + + const launcher = yield* ThreadLaunch.ThreadLaunchService; + const originalLaunch = launcher.launch; + let deletedLaunch: ThreadLaunch.ThreadLaunchResult | undefined; + const deleteBeforeLink = vi.spyOn(launcher, "launch").mockImplementation((input) => + originalLaunch(input).pipe( + Effect.tap((result) => + Effect.gen(function* () { + deletedLaunch = result; + yield* orchestrator + .dispatch({ + type: "thread.delete", + commandId: CommandId.make("command:delete-launch-before-link"), + threadId: result.threadId, + }) + .pipe(Effect.orDie); + }), + ), + ), + ); + const deletedLaunchWarnings: unknown[] = []; + const deletedLaunchCall = yield* invoke("t3_thread_launch", { + projectId: otherProjectId, + title: "Deleted before run link", + message: createdThreadPrompt, + }).pipe( + Effect.provide( + Logger.layer( + [ + Logger.make(({ logLevel, message }) => { + if (logLevel === "Warn") deletedLaunchWarnings.push(message); + }), + ], + { mergeWithExisting: false }, + ), + ), + Effect.ensuring(Effect.sync(() => deleteBeforeLink.mockRestore())), + ); + expect(deletedLaunch?.projection.runs).toHaveLength(1); + expect(deletedLaunchCall.structuredContent).toMatchObject({ + _tag: "OrchestratorMcpFailure", + code: "thread_not_found", + }); + expect(deletedLaunchWarnings).toEqual([]); + if (deletedLaunch === undefined) return yield* Effect.die("Launch was not accepted"); + expect(yield* launchGrant(deletedLaunch.threadId)).toMatchObject({ targetRunId: null }); + expect( + (yield* invoke("t3_thread_read", { threadId: deletedLaunch.threadId })) + .structuredContent, + ).toMatchObject({ code: "thread_not_found" }); + + const launchCall = yield* invoke("t3_thread_launch", { + projectId: otherProjectId, + title: "Launched in another project", + message: createdThreadPrompt, + }); + const launched = launchCall.structuredContent as { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly runId: string | null; + }; + expect(launched.projectId).toBe(otherProjectId); + expect(launched.runId).not.toBeNull(); + expect((yield* newOtherProjectThreads).map((thread) => thread.id)).toEqual([ + launched.threadId, + ]); + expect(yield* launchGrant(launched.threadId)).toMatchObject({ + targetRunId: launched.runId, + }); + const launchedListing = yield* decodeThreadListResult( + (yield* invoke("t3_thread_list", {})).structuredContent, + ); + expect( + launchedListing.threads.some((thread) => thread.threadId === launched.threadId), + ).toBe(true); + const siblingCallerId = ThreadId.make("thread:mcp-launch-sibling-caller"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("command:create-launch-sibling-caller"), + threadId: siblingCallerId, + projectId, + title: "Sibling caller", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + modelSelection: codexSelection, + runtimeMode: "full-access", + interactionMode: "default", + }); + const siblingRead = yield* invokeAs( + { ...invocation, threadId: siblingCallerId }, + "t3_thread_read", + { threadId: launched.threadId }, + ); + expect(siblingRead.structuredContent).toMatchObject({ code: "thread_not_found" }); + const siblingListing = yield* decodeThreadListResult( + (yield* invokeAs({ ...invocation, threadId: siblingCallerId }, "t3_thread_list", {})) + .structuredContent, + ); + expect( + siblingListing.threads.some((thread) => thread.threadId === launched.threadId), + ).toBe(false); + + const launchedRead = yield* decodeThreadReadResult( + (yield* invoke("t3_thread_read", { threadId: launched.threadId, view: "messages" })) + .structuredContent, + ).pipe(Effect.orDie); + expect(launchedRead.thread.projectId).toBe(otherProjectId); + const launchedWait = yield* invoke("t3_thread_wait", { + threadId: launched.threadId, + timeoutMs: 5000, + }); + expect(launchedWait.structuredContent).toMatchObject({ + threadId: launched.threadId, + status: "completed", + }); + const launchedSend = yield* invoke("t3_thread_send", { + threadId: launched.threadId, + message: "Confirm the launched workspace.", + mode: "queue", + clientRequestId: "send-launched-project-1", + }); + expect(launchedSend.structuredContent).toMatchObject({ threadId: launched.threadId }); + expect( + (yield* orchestrator.getThreadProjection(launched.threadId)).messages.find( + (message) => message.text === "Confirm the launched workspace.", + )?.senderThreadId, + ).toBe(parentThreadId); + const launchedInterrupt = yield* invoke("t3_thread_interrupt", { + threadId: launched.threadId, + clientRequestId: "interrupt-launched-project-1", + }); + expect(launchedInterrupt.structuredContent).toMatchObject({ + threadId: launched.threadId, + }); + yield* waitForProjection(orchestrator, launched.threadId, (projection) => + projection.runs.every((run) => + ["completed", "failed", "cancelled", "interrupted"].includes(run.status), + ), + ); + yield* threadManagement.dispatch({ + type: "thread.delete", + threadId: launched.threadId, + commandId: CommandId.make("delete-launched-target"), + }); + for (const tool of [ + "t3_thread_read", + "t3_thread_send", + "t3_thread_wait", + "t3_thread_interrupt", + ] as const) { + const denied = yield* invoke(tool, { + threadId: launched.threadId, + ...(tool === "t3_thread_send" ? { message: "after delete" } : {}), + }); + expect(denied.structuredContent).toMatchObject({ code: "thread_not_found" }); + } + const cancellableCall = yield* invoke("delegate_task", { task: cancellationPrompt, target: { @@ -2158,6 +2848,46 @@ describe("orchestrator MCP toolkit", () => { model: item.targetModel, })), ).toEqual([ + { + targetThreadId: repairTargetId, + targetRunId: repairedTarget.runs[0]!.id, + title: repairedTarget.thread.title, + providerInstanceId: codexInstanceId, + model: codexModel, + }, + { + targetThreadId: knownProjectThread.threadId, + targetRunId: knownProjectThread.runId, + title: knownProjectThread.title, + providerInstanceId: codexInstanceId, + model: codexModel, + }, + ...(faultStage !== "grant" + ? [ + { + targetThreadId: faultedTargetId, + targetRunId: + faultStage === "run-link-exhausted" ? null : faultedTarget.runs[0]!.id, + title: "Faulted launch", + providerInstanceId: codexInstanceId, + model: codexModel, + }, + ] + : []), + { + targetThreadId: deletedLaunch.threadId, + targetRunId: null, + title: "Deleted before run link", + providerInstanceId: codexInstanceId, + model: codexModel, + }, + { + targetThreadId: launched.threadId, + targetRunId: launched.runId, + title: "Launched in another project", + providerInstanceId: codexInstanceId, + model: codexModel, + }, { targetThreadId: emptyThread.threadId, targetRunId: null, @@ -2475,6 +3205,21 @@ describe("orchestrator MCP toolkit", () => { listed.threads.some((thread) => thread.relationshipToParent === "subagent"), ).toBe(false); + for (const archivedThreadId of [promptedThread.threadId, knownProjectThread.threadId]) { + expect(listed.threads.map((thread) => thread.threadId)).toContain(archivedThreadId); + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make(`archive-created:${archivedThreadId}`), + threadId: archivedThreadId, + }); + const afterArchive = yield* decodeThreadListResult( + (yield* invoke("t3_thread_list", { limit: 100 })).structuredContent, + ); + expect(afterArchive.threads.map((thread) => thread.threadId)).not.toContain( + archivedThreadId, + ); + } + // A wait-mode delegation whose blocking wait times out no longer // owns delivery, so delegate_task upgrades the task to "always". // Its terminal then wakes the parent even mid-turn. @@ -3602,6 +4347,23 @@ describe("orchestrator MCP toolkit", () => { thirdFanoutDelivery.messageId, ]), ); + yield* threadManagement.dispatch({ + type: "thread.delete", + threadId: parentThreadId, + commandId: CommandId.make("delete-creation-grant-owner"), + }); + for (const tool of [ + "t3_thread_read", + "t3_thread_send", + "t3_thread_wait", + "t3_thread_interrupt", + ] as const) { + const denied = yield* invoke(tool, { + threadId: knownProjectThread.threadId, + ...(tool === "t3_thread_send" ? { message: "after caller delete" } : {}), + }); + expect(denied.structuredContent).toMatchObject({ code: "thread_not_found" }); + } }).pipe(Effect.provide(testLayer)); }), ), @@ -3643,6 +4405,7 @@ describe("orchestrator MCP toolkit", () => { ), Layer.provide(providerRegistryLayer), Layer.provide(unusedScheduledTaskStubLayer), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(NodeServices.layer), ); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts index 18d91b50b5b1..9506dfa5ab10 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -6,6 +6,7 @@ import { DelegateTaskTool, OrchestratorCapabilitiesTool, ScheduleTaskTool, + OrchestratorToolkit, ThreadUpdateTool, } from "./tools.ts"; @@ -47,6 +48,22 @@ describe("orchestrator MCP tool guidance", () => { assert.include(timeoutText, "does not cancel the child"); }); + it("exposes known-project targeting only for ordinary thread creation", () => { + assert.include(CreateThreadsTool.description ?? "", "projectDirectory"); + assert.include(DelegateTaskTool.description ?? "", "always inherit this thread's project"); + for (const name of [ + "t3_thread_read", + "t3_thread_send", + "t3_thread_wait", + "t3_thread_interrupt", + ] as const) { + assert.include( + OrchestratorToolkit.tools[name].description ?? "", + "created through create_threads or t3_thread_launch", + ); + } + }); + it("documents slim and paginated capability discovery", () => { const schema = Tool.getJsonSchema(OrchestratorCapabilitiesTool) as { readonly properties?: Readonly>; diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 35ea7a2c0f83..f04de7bbbd6e 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -59,7 +59,7 @@ export const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities export const DelegateTaskTool = Tool.make("delegate_task", { description: - "Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. The childThreadId is backing storage, not an ordinary top-level thread. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", + "Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. Delegated children always inherit this thread's project. The childThreadId is backing storage, not an ordinary top-level thread. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -148,7 +148,7 @@ const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", { export const CreateThreadsTool = Tool.make("create_threads", { description: - "Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model; otherwise call delegate_task, including for same-provider work. Use create_threads for a batch of separate top-level threads sharing this checkout. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Project, branch, and worktree always inherit and cannot be overridden here. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy instead of asking the agent to create a worktree in its prompt.", + "Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model; otherwise call delegate_task, including for same-provider work. Use create_threads for a batch of separate top-level threads sharing this checkout. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Pass projectDirectory as an absolute or ~/ path to another known T3 project to use its root checkout. Unknown or relative paths are rejected. Without it project, branch, and worktree inherit. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy instead of asking the agent to create a worktree in its prompt.", parameters: OrchestratorMcpCreateThreadsInput, success: OrchestratorMcpCreateThreadsResult, failure: OrchestratorMcpFailure, @@ -161,7 +161,7 @@ export const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List T3 threads in the calling thread's project, newest first. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list) and paginate with the returned cursor. Threads from other projects are never exposed.", + "List T3 threads in the calling thread's project and exact threads this caller created through create_threads or t3_thread_launch, newest first. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list) and paginate with the returned cursor. Archived, deleted and unrelated cross-project threads are excluded. Each item has its own projectId; the top-level projectId identifies the caller's project.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, @@ -175,7 +175,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "Read durable state and a paginated timeline from a T3 thread in the calling project, or from a thread the user attached to this conversation as context. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units.", + "Read durable state and a paginated timeline from a T3 thread in the calling project, a thread this caller created through create_threads or t3_thread_launch, or a thread the user attached as read-only context. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, @@ -202,7 +202,7 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", { const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send a message to a T3 thread in the calling project. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", + "Send a message to a T3 thread in the calling project or an exact cross-project thread this caller created through create_threads or t3_thread_launch. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, @@ -215,7 +215,7 @@ const ThreadSendTool = Tool.make("t3_thread_send", { const ThreadWaitTool = Tool.make("t3_thread_wait", { description: - "Wait for a T3 thread run to reach a terminal durable state. Without runId, the latest run at call time is selected; an idle thread returns immediately. Timeout does not interrupt work, so call again or use t3_thread_read/list after timedOut=true. Waiting reports status only and does not acknowledge a delegated result.", + "Wait for a T3 thread run to reach a terminal durable state. Targets must belong to the calling project or be an exact cross-project thread this caller created through create_threads or t3_thread_launch. Without runId, the latest run at call time is selected; an idle thread returns immediately. Timeout does not interrupt work, so call again or use t3_thread_read/list after timedOut=true. Waiting reports status only and does not acknowledge a delegated result.", parameters: OrchestratorMcpThreadWaitInput, success: OrchestratorMcpThreadWaitResult, failure: OrchestratorMcpFailure, @@ -229,7 +229,7 @@ const ThreadWaitTool = Tool.make("t3_thread_wait", { const ThreadInterruptTool = Tool.make("t3_thread_interrupt", { description: - "Request interruption of a running turn in a T3 thread in the calling project. Without runId, the newest interruptible run is selected. Terminal runs and threads without an active turn return without another side effect. clientRequestId makes retries idempotent.", + "Request interruption of a running turn in a T3 thread in the calling project or an exact cross-project thread this caller created through create_threads or t3_thread_launch. Without runId, the newest interruptible run is selected. Terminal runs and threads without an active turn return without another side effect. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadInterruptInput, success: OrchestratorMcpThreadInterruptResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 68590c8b3429..bbdc7134fd90 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -17,6 +17,7 @@ import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementSer import * as ServerConfig from "../../../config.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import { ProjectHandlersLive } from "./handlers.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -65,6 +66,9 @@ it.effect("attributes a launched thread's first message to the calling thread", }, }), Layer.mock(Project.ProjectService)({}), + Layer.mock(OrchestratorMcpService.OrchestratorMcpService)({ + launchedThreadGrant: () => Effect.succeed(() => Effect.void), + }), NodeServices.layer, ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-link-" }).pipe( Layer.provide(NodeServices.layer), diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index b944cb3a8aab..06d8c15ff28b 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,10 +1,14 @@ import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; +import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import { newCommandId, readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -19,6 +23,16 @@ function projectFailure(error: Project.ProjectServiceError) { return new OrchestratorMcpFailure({ code: "invalid_request", message }); } +const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure); + +function launchFailure(error: Claims.AttachmentClaimError | ThreadLaunch.ThreadLaunchError) { + if (error._tag === "AttachmentClaimError") + return new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }); + if (error.operation === "record-creation" && isOrchestratorMcpFailure(error.cause)) + return error.cause; + return unavailable(); +} + const access = Effect.gen(function* () { yield* readCaller(); return yield* Project.ProjectService; @@ -45,6 +59,9 @@ export const ProjectHandlersLive = ProjectToolkit.toLayer({ code: "capability_denied", message: "Project launches require a full-access/default calling thread.", }); + // The caller's grant lets it reach the launched thread in any project. + const recordCreation = + yield* (yield* OrchestratorMcpService.OrchestratorMcpService).launchedThreadGrant(scope); const commandId = yield* newCommandId(); const threadId = ThreadId.make(commandId); const messageId = MessageId.make(commandId); @@ -75,15 +92,27 @@ export const ProjectHandlersLive = ProjectToolkit.toLayer({ }), createdBy: "agent", creationSource: "mcp", - }).pipe( - Effect.mapError((error) => - error._tag === "AttachmentClaimError" - ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) - : unavailable(), - ), - ); + onThreadCreated: (createdThreadId) => recordCreation(createdThreadId, null), + }).pipe(Effect.mapError(launchFailure)); const thread = result.projection.thread; const run = result.projection.runs.find((run) => run.userMessageId === messageId); + if (run !== undefined) { + yield* recordCreation(thread.id, run.id).pipe( + Effect.retry({ + times: 3, + schedule: Schedule.exponential("25 millis"), + while: (error) => error.code === "orchestration_error", + }), + Effect.catch((error) => { + if (error.code !== "orchestration_error") return Effect.fail(error); + return Effect.logWarning("orchestrator-mcp.thread-launch.run-link-failed", { + threadId: thread.id, + runId: run.id, + code: error.code, + }); + }), + ); + } return { threadId: thread.id, projectId: thread.projectId, diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 938dfb277c83..6ac0531c6631 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -27,6 +27,7 @@ import * as ProjectService from "../../../project/ProjectService.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; const shared = { success: Project, @@ -96,7 +97,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. Requires a full-access/default caller.', + 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings. This caller keeps read, wait, send, and interrupt access to the launched thread, even in another project. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. Once accepted, a failed timeline run link does not hide the successful threadId/runId result. After other errors or lost responses, inspect t3_thread_list before retrying, but a failed pre-dispatch creation grant can leave an idle cross-project thread that this caller cannot list. Attachments must be pending uploads. Requires a full-access/default caller.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), title: TrimmedNonEmptyString, @@ -127,6 +128,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { dependencies: [ ...shared.dependencies, ThreadLaunchService.ThreadLaunchService, + OrchestratorMcpService.OrchestratorMcpService, FileSystem.FileSystem, ServerConfig.ServerConfig, ], diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index e1b840277917..6ef1eccf33ab 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -7010,7 +7010,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .getThreadRecords( command.parentThreadId, ["runs", "nodes", "turnItems", "attempts", "providerTurns"], - { turnItemTypes: [], messageRoles: ["user"] }, + { turnItemTypes: ["thread_created"], messageRoles: ["user"] }, ) .pipe( Effect.mapError( @@ -7036,6 +7036,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + if ( + parentProjection.thread.deletedAt !== null || + targetProjection.thread.deletedAt !== null + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Cannot record creation for a deleted parent or target thread.", + }); + } const parentRun = parentProjection.runs.find( (candidate) => candidate.id === command.parentRunId, ); @@ -7054,13 +7064,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Parent node ${command.parentNodeId} is not the root of run ${command.parentRunId}.`, }); } - if (parentProjection.thread.projectId !== targetProjection.thread.projectId) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Target thread ${command.targetThreadId} belongs to another project.`, - }); - } if ( command.targetRunId !== null && !targetProjection.runs.some((candidate) => candidate.id === command.targetRunId) @@ -7074,7 +7077,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const now = yield* DateTime.now; const parentProviderTurn = providerTurnForRun(parentProjection, parentRun); - const turnItem: OrchestrationV2TurnItem = { + const existing = parentProjection.turnItems.find( + (item) => item.type === "thread_created" && item.targetThreadId === command.targetThreadId, + ); + // Concurrent grant attempts converge on the original row and its metadata. + const turnItem: OrchestrationV2TurnItem = existing ?? { id: idAllocator.derive.createdThreadTurnItem({ commandId: command.commandId }), threadId: command.parentThreadId, runId: command.parentRunId, @@ -7102,11 +7109,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio )({ type: "turn-item.updated", threadId: command.parentThreadId, - runId: command.parentRunId, - nodeId: command.parentNodeId, + runId: turnItem.runId ?? command.parentRunId, + nodeId: turnItem.nodeId ?? command.parentNodeId, providerInstanceId: parentRun.providerInstanceId, occurredAt: now, - payload: turnItem, + payload: + turnItem.type === "thread_created" && + turnItem.targetRunId === null && + command.targetRunId !== null + ? { ...turnItem, targetRunId: command.targetRunId, updatedAt: now } + : turnItem, }); }, ); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 02c28e74e376..6e956dd66e02 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -85,6 +85,8 @@ const otherProject = { title: "Other", } as const; +class GrantRefused extends Schema.TaggedError()("GrantRefused", {}) {} + const adapter = { instanceId: modelSelection.instanceId, driver: ProviderDriverKind.make("codex"), @@ -1396,6 +1398,57 @@ it.effect("rejects a server-allocated launch retry after the thread is deleted", }).pipe(Effect.provide(harness.layer)); }); +it.effect("runs the creation hook after the thread exists and before its first message", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const observed = yield* Ref.make< + ReadonlyArray<{ + readonly threadId: ThreadId; + readonly messages: number; + readonly runs: number; + }> + >([]); + const launched = yield* launches.launch({ + ...launchInput({ + command: "command:launch:hook", + thread: "thread:launch:hook", + message: "Go", + }), + onThreadCreated: (threadId) => + threads + .getThreadProjection(threadId) + .pipe( + Effect.flatMap((projection) => + Ref.update(observed, (entries) => [ + ...entries, + { threadId, messages: projection.messages.length, runs: projection.runs.length }, + ]), + ), + ), + }); + assert.deepEqual(yield* Ref.get(observed), [ + { threadId: launched.threadId, messages: 0, runs: 0 }, + ]); + assert.equal(launched.projection.runs.length, 1); + + const refused = launchInput({ + command: "command:launch:hook-refused", + thread: "thread:launch:hook-refused", + message: "Never sent", + }); + const failed = yield* launches + .launch({ ...refused, onThreadCreated: () => Effect.fail(new GrantRefused()) }) + .pipe(Effect.flip); + assert.equal(failed.operation, "record-creation"); + assert.instanceOf(failed.cause, GrantRefused); + const idle = yield* threads.getThreadProjection(refused.threadId); + assert.equal(idle.messages.length, 0); + assert.equal(idle.runs.length, 0); + }).pipe(Effect.provide(harness.layer)); +}); + it.effect("does not treat an unrelated accepted command receipt as a launch", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1784,6 +1837,15 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch }).pipe(Effect.flip); assert.equal(missingProject._tag, "ThreadLaunchError"); assert.equal((yield* claimedFiles).length, 1); + // A refused creation hook runs before the message, so its claims are unused. + const refusedGrant = yield* ThreadMessageIntake.launchThread({ + ...input, + commandId: CommandId.make("intake-refused-grant"), + threadId: ThreadId.make("intake-refused-grant-thread"), + onThreadCreated: () => Effect.fail(new GrantRefused()), + }).pipe(Effect.flip); + assert.equal(refusedGrant._tag, "ThreadLaunchError"); + assert.equal((yield* claimedFiles).length, 1); const missingThread = yield* ThreadMessageIntake.dispatchCommand({ type: "message.dispatch", commandId: CommandId.make("intake-no-thread"), diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 8abcc9c68a55..c717ecea2fc1 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -87,6 +87,12 @@ export interface ThreadLaunchInput { }; readonly createdBy: OrchestrationV2Actor; readonly creationSource: OrchestrationV2CreationSource; + /** + * Runs after the thread exists and before its initial message is dispatched, so + * a caller can durably record ownership before any work starts. A failure stops + * the launch and leaves the thread idle. + */ + readonly onThreadCreated?: (threadId: ThreadId) => Effect.Effect; } export interface ThreadLaunchResult { @@ -106,6 +112,7 @@ export class ThreadLaunchError extends Schema.TaggedError()( "run-setup-script", "create-thread", "update-thread", + "record-creation", "dispatch-message", "release-run", "fail-run", @@ -716,6 +723,11 @@ const make = Effect.gen(function* () { if (project.id !== input.projectId) { return yield* mapError(input, "resolve-project", threadId)("Project identity changed."); } + if (input.onThreadCreated !== undefined) { + yield* input + .onThreadCreated(threadId) + .pipe(Effect.mapError(mapError(input, "record-creation", threadId))); + } let runId: RunId | null = null; let messageWasAlreadyAccepted = false; diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index b3c37e2fea83..38087ecc6449 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -220,11 +220,13 @@ export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(functi ), ), Effect.tapError((error) => { - // Project/receipt reads precede message dispatch. The create-thread error - // also wraps post-message projection reads, so its tag alone is not proof. + // Project/receipt reads and the creation hook precede message dispatch. The + // create-thread error also wraps post-message projection reads, so its tag + // alone is not proof. const notAccepted = error.operation === "resolve-project" || error.operation === "read-receipt" || + error.operation === "record-creation" || ((error.operation === "create-thread" || error.operation === "dispatch-message") && isOrchestratorError(error.cause) && // Projection errors under create-thread can occur after the message commit. diff --git a/apps/server/src/provider/T3OrchestrationInstructions.test.ts b/apps/server/src/provider/T3OrchestrationInstructions.test.ts index 8382e7e03ec8..b29a58e0f89d 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.test.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.test.ts @@ -21,6 +21,11 @@ describe("T3 orchestration provider instructions", () => { assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "includeModelOptions: true"); }); + it("documents known-project targeting without widening delegation", () => { + assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "projectDirectory"); + assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "Delegated children always inherit"); + }); + it("documents structured schedules instead of JSON strings", () => { assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "structured object, never as JSON text"); assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, '"everyMs":3600000'); diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/apps/server/src/provider/T3OrchestrationInstructions.ts index 1255229843f5..d20e7261e4a7 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.ts @@ -6,7 +6,7 @@ export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = ` The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly: -- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models: call \`orchestrator_capabilities({})\` for provider summaries, pass \`providerInstanceId\` to page that provider's models with \`modelCursor\`, and request an exact \`model\` with \`includeModelOptions: true\` only when its option descriptors are needed. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent; do not replace delegation with ordinary thread creation. +- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models: call \`orchestrator_capabilities({})\` for provider summaries, pass \`providerInstanceId\` to page that provider's models with \`modelCursor\`, and request an exact \`model\` with \`includeModelOptions: true\` only when its option descriptors are needed. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Delegated children always inherit the caller's project. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent; do not replace delegation with ordinary thread creation. - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. - \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule. By default runs return to the current thread; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling, report the returned cadence and next run time. @@ -20,7 +20,7 @@ For independent implementation or a PR stack in its own worktree, use \`t3_threa For stacked work, set \`baseRef\` to the intended parent branch and \`startFromOrigin:false\` to use its local commits. Use \`startFromOrigin:true\` when you intend to fetch and start from origin. Uncommitted edits are not copied. Use \`t3_worktree_list\` to discover existing checkout paths. Project, model selection, and modes inherit unless supplied; launch requires a full-access/default caller. -\`t3_thread_launch\` is the single-thread launch tool. Use \`create_threads\` only for a batch of threads intentionally sharing the caller's checkout: it always inherits the caller's project, branch, and worktree and has no workspace override. Asking an agent to run \`git worktree add\` or \`cd\` in its prompt does not update T3's thread binding. Select the workspace in the launch call instead. \`t3_worktree_handoff\` moves the calling thread, not another thread, and cannot move a thread already attached to a worktree. +\`t3_thread_launch\` is the single-thread launch tool. Use \`create_threads\` only for a batch of threads intentionally sharing the caller's checkout: it inherits the caller's project, branch, and worktree unless an entry supplies \`projectDirectory\`, an absolute or \`~/\` path to another known project's root checkout. Unknown or relative paths are rejected. \`create_threads\` never selects a worktree; use launch for that. Asking an agent to run \`git worktree add\` or \`cd\` in its prompt does not update T3's thread binding. Select the workspace in the launch call instead. \`t3_worktree_handoff\` moves the calling thread, not another thread, and cannot move a thread already attached to a worktree. \`t3_thread_launch\` has no idempotency key. Retain its returned threadId and inspect it with \`t3_thread_read\` / \`t3_thread_wait\`; preparation can still be running after acceptance. If a launch fails or its response is lost, inspect \`t3_thread_list\` before retrying, since a thread may already exist. diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 6d1f2af8c170..f5f0ac0f66ab 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -228,7 +228,8 @@ type DelegateTaskInput = { ``` Provider, model, runtime mode, and interaction mode inherit from the parent -when omitted. A driver-only target inherits the parent's provider instance +when omitted. Delegated children always inherit the parent thread's project. +A driver-only target inherits the parent's provider instance when it can run child tasks, and otherwise selects an available instance of that driver; an explicit `providerInstanceId` is honored exactly and fails when unavailable. Selecting a different provider without a model uses that @@ -297,15 +298,18 @@ type CreateThreadsInput = { }; runtimeMode?: "inherit" | "approval-required" | "auto-accept-edits" | "full-access"; interactionMode?: "inherit" | "plan" | "default"; + projectDirectory?: string; }>; clientRequestId?: string; }; ``` -Each entry independently resolves provider, model, and modes. The new threads -inherit the parent's project, branch, and worktree path, but they have no -sub-agent lineage. Entries with a prompt immediately dispatch a run; entries -without a prompt remain idle. +Each entry independently resolves provider, model, and modes. Without +`projectDirectory`, it inherits the parent's project, branch, and worktree. +An absolute or `~/` directory must match a registered, available project root; +that selection starts at the target root with no inherited branch or worktree. +Unknown and relative paths are rejected. Entries have no subagent lineage; +a prompt immediately dispatches a run, otherwise the thread remains idle. ### `t3_thread_launch` @@ -322,12 +326,25 @@ workspace does not. For stacked PRs, use the parent branch as `baseRef` with no retry key, so inspect existing threads after a failed or lost response before launching again. `create_threads` remains the batch option for a shared checkout. +An MCP caller's launch records the new thread in the caller's durable timeline +before the thread's first message is dispatched. That record lets the caller +read, send, wait, or interrupt the thread even when `projectId` names another +known project. Run-link failures get four attempts with short exponential delays +and fresh command IDs, without resubmitting the accepted message. If all attempts +fail, launch still returns the accepted thread and run IDs and status. The durable +creation record keeps access and listing available after process loss. This does +not broaden project listing or metadata access; delegation stays in the caller's +project. Deleting the caller ends grant access, checked against durable deletion +state on every request, including after restart or replay. + ### `t3_thread_list` -Lists durable thread shells in the calling thread's project, newest first. +Lists durable thread shells in the calling thread's project and exact threads +it created through `create_threads` or `t3_thread_launch`, newest first. Callers can filter by title, run status, and whether app-owned sub-agent threads -are included. Results are bounded and offset-paginated. Deleted threads and -threads from other projects are never exposed. +are included. Results are bounded and offset-paginated. Archived, deleted and +unrelated cross-project threads are excluded. Each item has its own `projectId`; +the top-level `projectId` identifies the caller's project. ### `t3_thread_read` @@ -427,9 +444,16 @@ results use the latest assistant content from the final work turn. mode. It may not escalate privileges. - A child interaction mode may stay equal to or narrow from `default` to `plan`. It may not escalate from `plan` to `default`. -- General thread management is limited to the calling thread's project. Send - additionally enforces the same runtime and interaction privilege ceiling as - child creation. +- `create_threads` inherits this thread's project unless `projectDirectory` + names another known T3 project workspace. Absolute paths and home-relative + `~/` paths are accepted. Unknown or relative paths are rejected. Delegated + subagents always inherit the parent project. +- `t3_thread_list` stays in the calling thread's project. Read, send, wait, and + interrupt may follow a top-level thread this parent recorded creating through + `create_threads` or `t3_thread_launch`, even when that thread lives in another + known project. +- Send additionally enforces the same runtime and interaction privilege ceiling + as child creation. - Provider instances must be enabled, installed, available, authenticated, and backed by a V2 adapter. - A requested model must be advertised by the selected provider when the diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index df5e1015d6c8..697de08c5fd2 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -26,6 +26,17 @@ const decodeThreadSendInput = Schema.decodeUnknownSync(OrchestratorMcpThreadSend const decodeThreadWaitInput = Schema.decodeUnknownSync(OrchestratorMcpThreadWaitInput); describe("orchestrator MCP contracts", () => { + it("allows projectDirectory only for top-level creation", () => { + const input = { prompt: "Inspect", projectDirectory: "/known/project" }; + expect(decodeCreateThreadsInput({ threads: [input] }).threads[0]?.projectDirectory).toBe( + input.projectDirectory, + ); + expect( + decodeDelegateTaskInput({ task: "Inspect", projectDirectory: "/other" }), + ).not.toHaveProperty("projectDirectory"); + expect(OrchestratorMcpDelegateTaskInput.fields).not.toHaveProperty("projectDirectory"); + }); + it("decodes slim and expanded capability requests", () => { expect(decodeCapabilitiesInput({})).toEqual({}); expect( diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 234cc9344b76..d5c0afbf1c99 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -46,6 +46,12 @@ const OrchestratorMcpTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512) const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check( Schema.isMaxLength(256), ).annotate({ description: "Stable idempotency key to reuse when retrying this mutation." }); +const OrchestratorMcpProjectDirectory = TrimmedNonEmptyString.check( + Schema.isMaxLength(4096), +).annotate({ + description: + "Optional path of a known T3 project workspace for a new top-level thread. Absolute paths and home-relative ~/ paths are accepted. Omit to inherit this thread's project. Unknown or relative paths are rejected.", +}); /** * OpenCode 1.15 has been observed serializing nested MCP union objects as JSON @@ -259,6 +265,7 @@ export const OrchestratorMcpCreateThreadRequest = Schema.Struct({ target: Schema.optional(OrchestratorMcpTarget), runtimeMode: Schema.optional(OrchestratorMcpRuntimeMode), interactionMode: Schema.optional(OrchestratorMcpInteractionMode), + projectDirectory: Schema.optional(OrchestratorMcpProjectDirectory), }); export type OrchestratorMcpCreateThreadRequest = typeof OrchestratorMcpCreateThreadRequest.Type; @@ -317,6 +324,7 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp export const OrchestratorMcpThreadListItem = Schema.Struct({ threadId: ThreadId, + projectId: ProjectId, title: Schema.String, createdBy: OrchestrationV2Actor, creationSource: OrchestrationV2CreationSource, @@ -338,7 +346,9 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ export type OrchestratorMcpThreadListItem = typeof OrchestratorMcpThreadListItem.Type; export const OrchestratorMcpThreadListResult = Schema.Struct({ - projectId: ProjectId, + projectId: ProjectId.annotate({ + description: "The calling thread's project, not necessarily each listed thread's project.", + }), currentThreadId: ThreadId, threads: Schema.Array(OrchestratorMcpThreadListItem), nextCursor: Schema.NullOr(NonNegativeInt), From 339aa2ba3a1d225f741023f2026361addc8c1fae Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sat, 26 Sep 2026 10:00:00 -0400 Subject: [PATCH 23/34] test(acp): Accept either shell exit status for a missing wrapped command --- .../src/provider/acp/AcpSessionRuntime.processTree.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts index 9067ee93f096..467c1b92506f 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts @@ -309,7 +309,8 @@ describe("terminatePosixOwnedProcessTree", () => { encoding: "utf8", }, ); - expect(missingTargetResult.status, missingTargetResult.stderr).toBe(125); + // dash runs the EXIT trap after a failed exec (125); bash as sh exits 127 without it. + expect([125, 127], missingTargetResult.stderr).toContain(missingTargetResult.status); } finally { packagedExecPath.mockRestore(); NodeFS.rmSync(scratch, { recursive: true, force: true }); From 88b8810887fb42a341cc47777edb1140c94bd871 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sat, 26 Sep 2026 17:37:47 -0400 Subject: [PATCH 24/34] fix(mobile): Link bundled Oniguruma and React Native's fbjni in Android shiki The react-native-shiki-engine patch now also drops the library's "com.facebook.fbjni:fbjni:+" dependency. The dynamic version resolved the newest fbjni (0.8.1, built with NDK r28), which overrode React Native's 0.7.0. Its libfbjni.so needs __cxa_init_primary_exception from a newer libc++_shared.so than the NDK 27.1 copy the app packages, so release APKs crashed in MainApplication.onCreate before any UI appeared. fbjni still reaches the library's prefab build through react-android's API dependency. --- .../react-native-shiki-engine@0.3.12.patch | 31 +++++++++++++++++++ pnpm-lock.yaml | 5 +-- pnpm-workspace.yaml | 3 ++ 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 patches/react-native-shiki-engine@0.3.12.patch diff --git a/patches/react-native-shiki-engine@0.3.12.patch b/patches/react-native-shiki-engine@0.3.12.patch new file mode 100644 index 000000000000..c7063a93eb18 --- /dev/null +++ b/patches/react-native-shiki-engine@0.3.12.patch @@ -0,0 +1,31 @@ +diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt +index 005caaf255529389c2684dbe8fbf0cb099d80e97..34357afa65d7c948d86cd23d81ccbe338c9f981f 100644 +--- a/android/CMakeLists.txt ++++ b/android/CMakeLists.txt +@@ -5,8 +5,11 @@ set(CMAKE_VERBOSE_MAKEFILE ON) + + # Find prebuilt oniguruma library + find_library(LOG_LIB log) ++# Only the bundled per-ABI build: the default search paths include the build ++# host's /usr/lib, where a host libonig.so cannot link into an Android target. + find_library(ONIG_LIB onig + PATHS ${CMAKE_CURRENT_SOURCE_DIR}/src/main/jniLibs/${ANDROID_ABI} ++ NO_DEFAULT_PATH + NO_CMAKE_FIND_ROOT_PATH + REQUIRED + ) +diff --git a/android/build.gradle b/android/build.gradle +index 767eb43d1d9329215c12291c55034c964bcb059c..744db668ff3415227b78ef596c700aac1d4015ec 100644 +--- a/android/build.gradle ++++ b/android/build.gradle +@@ -111,7 +111,9 @@ dependencies { + //noinspection GradleDynamicVersion + implementation "com.facebook.react:react-native:+" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +- implementation "com.facebook.fbjni:fbjni:+" ++ // fbjni comes from react-android at React Native's version. A "+" here ++ // resolves the newest release, whose libfbjni.so can need a newer libc++ ++ // than the app's NDK packages. + } + + if (isNewArchitectureEnabled()) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d62416ece1ea..486fdbc9f49d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,7 @@ patchedDependencies: react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 react-native-reanimated@4.5.5: bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d react-native-screens@4.26.2: 595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0 + react-native-shiki-engine@0.3.12: a3f0a012967edcb4ce37c2754886b1e23a7a1bdc3d16992fe6880310349fb770 uniwind@1.11.0: a91f3953483cfd434436de6358ff5b63c49974ef148fad91246c26ba95332625 importers: @@ -465,7 +466,7 @@ importers: version: 4.26.2(patch_hash=595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 - version: 0.3.12(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.3.12(patch_hash=a3f0a012967edcb4ce37c2754886b1e23a7a1bdc3d16992fe6880310349fb770)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-svg: specifier: 15.15.4 version: 15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -21135,7 +21136,7 @@ snapshots: react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-shiki-engine@0.3.12(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-shiki-engine@0.3.12(patch_hash=a3f0a012967edcb4ce37c2754886b1e23a7a1bdc3d16992fe6880310349fb770)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@shikijs/types': 4.3.0 '@shikijs/vscode-textmate': 10.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 142b6546979d..25c6a1c14f16 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -212,6 +212,9 @@ patchedDependencies: # Preserve the final layout frame. Backport of [#10171](https://github.com/software-mansion/react-native-reanimated/pull/10171). react-native-reanimated@4.5.5: patches/react-native-reanimated@4.5.5.patch react-native-screens@4.26.2: patches/react-native-screens@4.26.2.patch + # Link the bundled Oniguruma on Android, never a build host's /usr/lib copy, + # and take fbjni from React Native instead of the newest release. + react-native-shiki-engine@0.3.12: patches/react-native-shiki-engine@0.3.12.patch uniwind@1.11.0: patches/uniwind@1.11.0.patch peerDependencyRules: From 2dff93e216d3e3e45c8229fd380230ed98b2a1d2 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:11:57 -0700 Subject: [PATCH 25/34] feat(server): report OpenCode 2 usage, context windows, models and history Carries pingdotgg/t3code#14270 oc2-4b-adapter-usage unchanged, squashed to one commit with Julius Marminge's authorship. Drop this layer once it merges into codex-turn-mapping. --- .../Adapters/OpenCode2AdapterV2.test.ts | 174 +++++++++++++++++- .../Adapters/OpenCode2AdapterV2.ts | 166 ++++++++++++++++- ...penCode2OrchestratorV2.integration.test.ts | 26 +++ .../OpenCode2OrchestratorV2.live.test.ts | 17 +- .../opencode_transcript.ndjson | 2 + .../opencode_transcript.ndjson | 6 +- .../fixtures/opencode2_simple/output.ts | 18 +- .../opencode_transcript.ndjson | 2 + .../fixtures/opencode2_tool_call/output.ts | 10 + .../src/provider/Drivers/OpenCodeDriver.ts | 17 ++ .../provider/Layers/OpenCodeProvider.test.ts | 89 ++++++++- .../src/provider/Layers/OpenCodeProvider.ts | 133 +++++++++++-- 12 files changed, 618 insertions(+), 42 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 0315014f7972..3fe4940fe951 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -77,6 +77,30 @@ const sessionInfo = (overrides: Record = {}) => ({ location: { directory: WORK }, ...overrides, }); +// `/api/model` as 2.0.18 lists big-pickle: its 160k input limit is the usable window. +const modelCatalog = { + location: { directory: WORK }, + data: [ + { + id: "big-pickle", + modelID: "big-pickle", + providerID: "opencode", + family: "big-pickle", + name: "Big Pickle", + compatibility: { reasoningField: "reasoning_content" }, + package: "@opencode/ai/providers/openai-compatible", + settings: { apiKey: "public", baseURL: "https://opencode.ai/zen/v1", provider: "opencode" }, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 1760659200000 }, + cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], + status: "active", + enabled: true, + limit: { context: 200000, input: 160000, output: 32000 }, + }, + ], +}; + const promptAccepted = replyData("session.prompt", { id: "msg_0eb735d41001NJee1EvVePJAK5", sessionID: SESSION, @@ -86,6 +110,13 @@ const promptAccepted = replyData("session.prompt", { delivery: "steer", }); +/** What every session sends when it opens: the event stream, then the model list. */ +const opening: ReadonlyArray = [ + out("event.subscribe"), + out("model.list", ""), + reply("model.list", modelCatalog), +]; + const bigPickle: ModelSelection = { instanceId, model: "opencode/big-pickle" }; const policy = (runtimeMode: "full-access" | "approval-required" = "full-access") => ({ runtimeMode, @@ -142,7 +173,7 @@ const resumed = (entries: ReadonlyArray, options?: { extern Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime( [ - out("event.subscribe"), + ...opening, out("session.get", { sessionID: SESSION }), replyData("session.get", sessionInfo()), ...entries, @@ -168,6 +199,39 @@ const terminalOf = (runtime: ProviderAdapterV2SessionRuntime) => Effect.map(Option.getOrUndefined), ); +// The history the spike read back after its `simple` turn (recordings/simple.ndjson). +const history = { + data: [ + { + id: "msg_0eb732081001RntUJfRtTXOAjd", + time: { created: 1790656585885 }, + text: "Think carefully step by step about whether 391 is prime, showing your reasoning, then answer in one short sentence.", + type: "user", + }, + { + id: "msg_0eb7320a9001vve3OV5uNi2HRT", + time: { created: 1790656585925, streamed: 1790656590719, completed: 1790656590736 }, + type: "assistant", + agent: "build", + model: { id: "space-bunny-free", providerID: "opencode", variant: "high" }, + content: [ + { type: "reasoning", text: "Check divisibility up to sqrt(391)." }, + { type: "text", text: "391 is not prime: it's the product 17 × 23." }, + ], + finish: "stop", + cost: 0, + tokens: { input: 8701, output: 113, reasoning: 147, cache: { read: 489, write: 0 } }, + }, + { + id: "msg_0eb733399001NhwTrB32UU6d6H", + time: { created: 1790656590745 }, + type: "idle", + outcome: "succeeded", + }, + ], + cursor: {}, +}; + describe("OpenCode2 adapter", () => { it.effect("switches the session's model and variant before a turn that changed them", () => Effect.gen(function* () { @@ -281,7 +345,7 @@ describe("OpenCode2 adapter", () => { it.effect("denies the subagent tool on the sessions it creates", () => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime([ - out("event.subscribe"), + ...opening, out("session.create", { location: { directory: WORK }, model: { providerID: "opencode", id: "big-pickle" }, @@ -567,7 +631,7 @@ describe("OpenCode2 adapter", () => { it.effect("refuses to resume a thread without an OpenCode session as a protocol error", () => Effect.gen(function* () { - const runtime = yield* openCode2ReplayRuntime([out("event.subscribe")]); + const runtime = yield* openCode2ReplayRuntime(opening); const failed = yield* runtime .resumeThread({ providerThread: { ...providerThread(yield* DateTime.now), nativeThreadRef: null }, @@ -580,7 +644,7 @@ describe("OpenCode2 adapter", () => { it.effect("gives a resumed session T3's rules when it was made with others", () => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime([ - out("event.subscribe"), + ...opening, out("session.get", { sessionID: SESSION }), // Made before the subagent rule: it still allows everything. replyData( @@ -602,7 +666,7 @@ describe("OpenCode2 adapter", () => { it.effect("moves the session when the thread's worktree changed", () => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime([ - out("event.subscribe"), + ...opening, out("session.get", { sessionID: SESSION }), replyData("session.get", sessionInfo()), out("session.move", { sessionID: SESSION, directory: "/work/opencode2-feature" }), @@ -622,7 +686,7 @@ describe("OpenCode2 adapter", () => { () => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime([ - out("event.subscribe"), + ...opening, out("session.get", { sessionID: SESSION }), replyData("session.get", sessionInfo()), out("session.move", { sessionID: SESSION, directory: "/work/opencode2-feature" }), @@ -665,7 +729,7 @@ describe("OpenCode2 adapter", () => { it.effect("refuses a model slug that is not provider/model before creating a session", () => Effect.gen(function* () { // Nothing but the session's opening is expected: no create, no prompt. - const runtime = yield* openCode2ReplayRuntime([out("event.subscribe")]); + const runtime = yield* openCode2ReplayRuntime([...opening]); const created = yield* runtime .ensureThread({ threadId, @@ -820,6 +884,102 @@ describe("OpenCode2 adapter", () => { ); }).pipe(Effect.scoped), ); + it.effect("reads user and assistant text from the session's message list", () => + Effect.gen(function* () { + const runtime = yield* openCode2ReplayRuntime([ + ...opening, + out("message.list", { sessionID: SESSION, order: "asc", limit: "100" }), + reply("message.list", history), + ]); + const snapshot = yield* runtime.readThreadSnapshot({ + providerThread: providerThread(yield* DateTime.now), + }); + assert.deepEqual( + snapshot.messages.map((message) => [message.role, message.text]), + [ + ["user", history.data[0]!.text], + ["assistant", "391 is not prime: it's the product 17 × 23."], + ], + ); + assert.equal( + snapshot.providerThread.nativeConversationHeadRef?.nativeId, + history.data[0]!.id, + ); + }).pipe(Effect.scoped), + ); + + it.effect("ends a turn before re-reading a model list that never answers", () => + Effect.gen(function* () { + // The session opened before the catalog loaded, so the turn's model has no window. + const runtime = yield* openCode2ReplayRuntime([ + out("event.subscribe"), + out("model.list", ""), + reply("model.list", { location: { directory: WORK }, data: [] }), + out("session.get", { sessionID: SESSION }), + replyData("session.get", sessionInfo()), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + out("model.list", ""), + reply("model.list", ""), + ]); + const thread = yield* runtime.resumeThread({ + providerThread: providerThread(yield* DateTime.now), + threadId, + modelSelection: bigPickle, + runtimePolicy: policy(), + }); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + // The terminal did not wait on the re-read, which is still in flight. + for (let i = 0; i < 20; i++) yield* Effect.yieldNow; + }).pipe(Effect.scoped), + ); + + it.effect("reports cache writes as cache creation, not only as input", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + // A step from a provider that reports prompt-cache writes. + event("session.step.ended", { + sessionID: SESSION, + assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z", + finish: "stop", + cost: 0, + tokens: { input: 1200, output: 40, reasoning: 0, cache: { read: 300, write: 2500 } }, + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const turn = yield* runtime.events.pipe( + Stream.filter( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.completedAt !== null, + ), + Stream.runHead, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const settled = Option.getOrUndefined(yield* Fiber.join(turn)); + assert.deepInclude( + settled?.type === "provider_turn.updated" ? settled.providerTurn.turnTokenUsage : undefined, + { + inputTokens: 1200 + 300 + 2500, + cachedInputTokens: 300, + cacheCreationTokens: 2500, + outputTokens: 40, + }, + ); + }).pipe(Effect.scoped), + ); + + it.effect("reports a model's input limit as its context window", () => + Effect.gen(function* () { + const runtime = yield* openCode2ReplayRuntime([...opening]); + assert.equal(runtime.getModelContextWindow?.(bigPickle), 160000); + }).pipe(Effect.scoped), + ); }); /** The provider turn the adapter derives for `turnInput`'s attempt. */ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index b0a1ac26dc5b..fd2068fa10ea 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -40,7 +40,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as ServerConfig from "../../config.ts"; -import type { OpenCode2StreamEvent } from "../../provider/opencode2/OpenCode2Client.ts"; +import { paginate, type OpenCode2StreamEvent } from "../../provider/opencode2/OpenCode2Client.ts"; import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts"; import { parseOpenCodeModelSlug, @@ -68,7 +68,7 @@ const OpenCode2ProviderCapabilities = { }, threads: { canCreateEmptyThread: true, - canReadThreadSnapshot: false, + canReadThreadSnapshot: true, canRollbackThread: false, canForkThread: false, canForkFromTurn: false, @@ -139,7 +139,7 @@ const OpenCode2ProviderCapabilities = { supportsNestedCheckpointScopes: true, providerCanRollbackConversation: false, providerRollbackReturnsSnapshot: false, - providerCanReadConversationSnapshot: false, + providerCanReadConversationSnapshot: true, }, identity: { nativeThreadIds: "strong", @@ -152,6 +152,7 @@ const OpenCode2ProviderCapabilities = { } satisfies OrchestrationV2ProviderCapabilities; type EventOf = Extract; +type Tokens = EventOf<"session.step.ended">["data"]["tokens"]; interface ActiveTurn { readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; @@ -162,6 +163,16 @@ interface ActiveTurn { readonly startedAt: Map; readonly ordinals: Map; nextOrdinal: number; + /** Input includes cache reads and writes, as 1.x reports it; the parts are also kept apart. */ + readonly usage: { + input: number; + cached: number; + cacheWrite: number; + output: number; + reasoning: number; + }; + steps: number; + lastStep: Tokens | undefined; interrupted: boolean; /** * Set on a turn started after a timed-out Stop's run left the server: that @@ -299,6 +310,25 @@ const sameModel = (left: ModelRef, right: ModelRef | undefined) => left.id === right?.id && (left.variant ?? "default") === (right?.variant ?? "default"); +/** The turn's own tokens: steps add up, and the last step's input is the live context size. */ +const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["status"]) => + turn.steps === 0 + ? { + usageScope: "main_agent" as const, + usageStatus: "unavailable" as const, + hasSubagents: false, + } + : { + usageScope: "main_agent" as const, + usageStatus: status === "completed" ? ("complete" as const) : ("partial" as const), + inputTokens: turn.usage.input, + cachedInputTokens: turn.usage.cached, + cacheCreationTokens: turn.usage.cacheWrite, + outputTokens: turn.usage.output, + reasoningTokens: turn.usage.reasoning, + hasSubagents: false, + }; + /** * The adapter for one provider instance. It talks to the instance's * {@link OpenCode2Server.OpenCode2Server}, which the driver builds from the instance's settings. @@ -308,12 +338,15 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const idAllocator = yield* IdAllocator.IdAllocatorV2; const serverConfig = yield* ServerConfig.ServerConfig; const driver = OPENCODE_PROVIDER; + // Context windows by `provider/model`, from the latest `/api/model` read. + const contextWindows = new Map(); const openSession = Effect.fn("OpenCode2Adapter.openSession")(function* ( input: Parameters[0], connection: OpenCode2Server.OpenCode2Connection, ) { const { client } = connection; + const sessionScope = yield* Effect.scope; const now = yield* DateTime.now; let session: OrchestrationV2ProviderSession = { id: input.providerSessionId, @@ -540,10 +573,27 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr terminal.status === "completed" ? "completed" : "interrupted", ); } + const window = contextWindows.get(turn.input.modelSelection.model); + const lastStep = turn.lastStep; yield* emitProviderTurn(state, turn, { ...turn.providerTurn, status: terminal.status, completedAt, + turnTokenUsage: turnTokenUsage(turn, terminal.status), + ...(lastStep === undefined + ? {} + : { + tokenUsage: { + usedTokens: + lastStep.input + lastStep.cache.read + lastStep.cache.write + lastStep.output, + maxTokens: window ?? null, + inputTokens: lastStep.input + lastStep.cache.read + lastStep.cache.write, + cachedInputTokens: lastStep.cache.read, + outputTokens: lastStep.output, + reasoningOutputTokens: lastStep.reasoning, + updatedAt: DateTime.formatIso(completedAt), + }, + }), }); state.providerThread = { ...state.providerThread, @@ -575,6 +625,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } : { ...base, status: terminal.status, failure: null }, ); + // A spawned server lists its models lazily, so a window still unknown is + // read again for the next turn, off this stream so it never delays one. + if (window === undefined) yield* Effect.forkIn(readModels, sessionScope); }); /** @@ -722,6 +775,19 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr turn.tools.delete(event.data.id); return; } + case "session.step.ended": + case "session.step.failed": { + const tokens = event.data.tokens; + if (tokens === undefined) return; + turn.steps += 1; + turn.lastStep = tokens; + turn.usage.input += tokens.input + tokens.cache.read + tokens.cache.write; + turn.usage.cached += tokens.cache.read; + turn.usage.cacheWrite += tokens.cache.write; + turn.usage.output += tokens.output + tokens.reasoning; + turn.usage.reasoning += tokens.reasoning; + return; + } case "session.execution.succeeded": return yield* finishTurn(state, { status: turn.interrupted ? "interrupted" : "completed", @@ -781,6 +847,27 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); } + // Context windows come from the server's model list, read when the session + // opens and again after a turn whose model had none yet. + const readModels = client.model + .list({ location: { directory: session.cwd ?? serverConfig.cwd } }) + .pipe( + Effect.timeout("5 seconds"), + Effect.tap((models) => + Effect.sync(() => { + for (const model of models.data) { + // A model's input limit, when it has one, is its real headroom. + contextWindows.set( + `${model.providerID}/${model.id}`, + model.limit.input ?? model.limit.context, + ); + } + }), + ), + Effect.ignore({ log: true }), + ); + yield* readModels; + const register = ( providerThread: OrchestrationV2ProviderThread, sessionId: string, @@ -823,6 +910,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return session; }, events: Stream.fromQueue(events), + getModelContextWindow: (selection) => + selection.instanceId === instanceId ? contextWindows.get(selection.model) : undefined, ensureThread: (threadInput) => Effect.gen(function* () { if (threadInput.existingProviderThread?.nativeThreadRef != null) { @@ -981,6 +1070,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr startedAt: new Map(), ordinals: new Map(), nextOrdinal: turnInput.providerTurnOrdinal * 100 + 1, + usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0 }, + steps: 0, + lastStep: undefined, interrupted: false, awaitingStart: afterUnsettled, }; @@ -1169,12 +1261,68 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }), ), readThreadSnapshot: ({ providerThread }) => - Effect.fail( - new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ - driver, - providerThreadId: providerThread.id, - cause: notYet("history snapshots"), - }), + Effect.gen(function* () { + const sessionId = yield* sessionIdOf(providerThread); + const history = yield* paginate( + { sessionID: Session.ID.make(sessionId), order: "asc" as const, limit: 100 }, + client.message.list, + ).pipe(Stream.runCollect); + const snapshotAt = yield* DateTime.now; + const messages = history.flatMap((message): Array => { + const text = + message.type === "user" + ? message.text + : message.type === "assistant" + ? textOf(message.content) + : ""; + if (text.length === 0 || (message.type !== "user" && message.type !== "assistant")) { + return []; + } + const createdAt = message.time.created; + return [ + { + createdBy: message.type === "user" ? "user" : "agent", + creationSource: "provider", + id: idAllocator.derive.messageFromProviderItem({ + driver, + nativeItemId: message.id, + }), + threadId: providerThread.appThreadId ?? input.threadId, + runId: null, + nodeId: null, + role: message.type, + text, + attachments: [], + streaming: false, + createdAt, + updatedAt: createdAt, + }, + ]; + }); + const lastUser = history.findLast((message) => message.type === "user")?.id; + const state = threads.get(sessionId); + return { + providerThread: { + ...providerThread, + providerSessionId: input.providerSessionId, + nativeConversationHeadRef: lastUser === undefined ? null : ref(lastUser, "weak"), + status: "idle" as const, + updatedAt: snapshotAt, + }, + providerTurns: state === undefined ? [] : [...state.providerTurns.values()], + messages, + runtimeRequests: [], + }; + }).pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ + driver, + providerThreadId: providerThread.id, + cause, + }), + ), ), rollbackThread: (rollbackInput) => Effect.fail( diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 1f0084540289..98ee10c1ce41 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -85,8 +85,34 @@ const answeredPrompt = (text: string): ReadonlyArray => [ }), event("session.execution.succeeded", { sessionID: SESSION }), ]; +/** A `/api/model` entry as 2.0.18 lists it; a known window means no re-read after a turn. */ +const catalogModel = (id: string, name: string) => ({ + id, + modelID: id, + providerID: "opencode", + family: id, + name, + compatibility: { reasoningField: "reasoning_content" }, + package: "@opencode/ai/providers/openai-compatible", + settings: { apiKey: "public", baseURL: "https://opencode.ai/zen/v1", provider: "opencode" }, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 1760659200000 }, + cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], + status: "active", + enabled: true, + limit: { context: 200000, input: 160000, output: 32000 }, +}); const createdSession = (directory: string): ReadonlyArray => [ out("event.subscribe"), + out("model.list", ""), + reply("model.list", { + location: { directory }, + data: [ + catalogModel("big-pickle", "Big Pickle"), + catalogModel("mimo-v2.6-flash-free", "MiMo V2.6 Flash Free"), + ], + }), out("session.create", ""), reply("session.create", sessionInfo(directory)), ]; diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 50f31477f21c..9e695048e73f 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -52,6 +52,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as Orchestrator from "./Orchestrator.ts"; +import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import { worktreeRepairDependenciesTestLayer } from "./ProviderTurnStartService.testkit.ts"; import { OrchestrationV2LayerLive } from "./runtimeLayer.ts"; import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; @@ -143,7 +144,8 @@ const liveLayer = OrchestrationV2LayerLive.pipe( Layer.provide(CheckpointStore.layer.pipe(Layer.provide(vcsDriverRegistryLayer))), Layer.provide(serverConfigLayer), Layer.provide(serverSettingsLayer), - Layer.provide(providerInstanceRegistryLayer), + // Merged, not only provided: the test reads the same instance the orchestrator uses. + Layer.provideMerge(providerInstanceRegistryLayer), Layer.provide(ResetCreditCoordinator.layer), Layer.provide(backgroundPolicyLayer), Layer.provide(PlatformTestLayer), @@ -231,6 +233,18 @@ describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchest const path = yield* Path.Path; yield* fs.writeFileString(path.join(ROOT, "work", "hello.txt"), "hello from t3 live\n"); yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); + + // A status check starts a fresh server, which lists no models for its + // first few hundred milliseconds; the picker must still get them. + const instance = + yield* (yield* ProviderInstanceRegistry.ProviderInstanceRegistry).getInstance(INSTANCE); + assert.isDefined(instance); + const status = yield* instance!.snapshot.refresh; + assert.equal(status.status, "ready"); + assert.include( + status.models.map((model) => model.slug), + "opencode/big-pickle", + ); const orchestrator = yield* Orchestrator.OrchestratorV2; const threadId = ThreadId.make("thread:opencode2-live"); yield* orchestrator.dispatch({ @@ -265,6 +279,7 @@ describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchest assert.isDefined( first.turnItems.find((item) => item.type === "dynamic_tool" && item.toolName === "read"), ); + assert.isAbove(first.providerTurns[0]?.tokenUsage?.maxTokens ?? 0, 0); yield* send( threadId, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_interrupt/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_interrupt/opencode_transcript.ndjson index c602b06b365d..346c0b5e3606 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_interrupt/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_interrupt/opencode_transcript.ndjson @@ -1,5 +1,7 @@ {"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_interrupt", "metadata": {"source": "opencode2-spike/recordings/interrupt.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} +{"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": ""}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} {"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb737dbd001VzvgLDSnWZmKmp", "type": "server.connected", "data": {}}}} {"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790656609743, "updated": 1790656609743}, "title": "spike interrupt", "location": {"directory": ""}}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson index b4693de82df0..fd2760731e39 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson @@ -1,7 +1,9 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_simple", "metadata": {"source": "opencode2-spike/recordings/simple.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_simple", "metadata": {"source": "opencode2-spike/recordings/simple.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder. The session.create expectation is T3's request for the recorded model and variant."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} +{"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": ""}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} {"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73205b001QM7spc7rTwRevR", "type": "server.connected", "data": {}}}} -{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": {"location": "", "model": {"providerID": "opencode", "id": "space-bunny-free", "variant": "high"}, "permissions": ""}}} {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "projectID": "global", "model": {"id": "space-bunny-free", "providerID": "opencode", "variant": "high"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790656585840, "updated": 1790656585840}, "title": "spike simple", "location": {"directory": ""}}}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732070001MeC12AQ1Ds6a0a", "created": 1790656585840, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "slug": "sunny-pixel", "version": "2.0.18", "projectID": "global", "location": {"directory": ""}, "subpath": "", "title": "spike simple", "model": {"id": "space-bunny-free", "providerID": "opencode", "variant": "high"}}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 0, "version": 1}}}} {"type": "emit_inbound", "label": "project.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732078001oO5ghtqXkMav9d", "created": 1790656585848, "type": "project.updated", "data": {"id": "global", "canonical": "", "vcs": "git", "time": {"created": 1790656063718, "updated": 1790656063718, "active": 1790656585847}, "sandboxes": []}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/output.ts index c238110c27c8..de9cf10243b9 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/output.ts @@ -34,8 +34,18 @@ export function assertOpenCode2SimpleOutput( const reasoning = projection.turnItems.find((item) => item.type === "reasoning"); assert.include(reasoning?.type === "reasoning" ? reasoning.text : "", "17×23 = 391"); - assert.deepEqual( - projection.providerTurns.map((turn) => turn.status), - ["completed"], - ); + // `step.ended` usage becomes the turn's usage; the model's input limit is the window. + const [providerTurn] = projection.providerTurns; + assert.equal(providerTurn?.status, "completed"); + assert.deepInclude(providerTurn?.turnTokenUsage, { + usageStatus: "complete", + inputTokens: 8701 + 489, + cachedInputTokens: 489, + outputTokens: 113 + 147, + reasoningTokens: 147, + }); + assert.deepInclude(providerTurn?.tokenUsage, { + usedTokens: 8701 + 489 + 113, + maxTokens: 524288, + }); } diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/opencode_transcript.ndjson index 06bcfa7f50b4..2519ef8934a1 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/opencode_transcript.ndjson @@ -1,5 +1,7 @@ {"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_tool_call", "metadata": {"source": "opencode2-spike/recordings/tool_call.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f148ca2deffeJcwCnRQtb0YFNX", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} +{"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": ""}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} {"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb735d1f001svh3y4Umi1226r", "type": "server.connected", "data": {}}}} {"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f148ca2deffeJcwCnRQtb0YFNX", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790656601394, "updated": 1790656601394}, "title": "spike tool_call", "location": {"directory": ""}}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/output.ts index 62003aa5bdea..7118276536f8 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/output.ts @@ -43,4 +43,14 @@ export function assertOpenCode2ToolCallOutput( const shell = projection.turnItems.find((item) => item.type === "command_execution"); assert.equal(shell?.status, "completed"); assert.deepInclude(shell, { input: "echo TOOL_OK", output: "TOOL_OK\n", exitCode: 0 }); + + // Two steps: their usage adds up, and the last one is the live context size. + const [providerTurn] = projection.providerTurns; + assert.deepInclude(providerTurn?.turnTokenUsage, { + usageStatus: "complete", + inputTokens: 8767 + 487 + 45 + 9311, + outputTokens: 59 + 33, + }); + // big-pickle lists a 200k context but takes 160k of input. + assert.deepInclude(providerTurn?.tokenUsage, { usedTokens: 45 + 9311 + 33, maxTokens: 160000 }); } diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index f84192842152..ef0025afa93a 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -34,6 +34,7 @@ import { ProviderDriverError } from "../Errors.ts"; import { readOpenCodeGoUsageLimits } from "../Layers/openCodeUsageLimits.ts"; import { checkOpenCodeProviderStatus, + makeOpenCode2ModelLoader, makePendingOpenCodeProvider, openCodeSkillsToServerProviderSkills, openCodeCommandsToServerProviderSlashCommands, @@ -263,6 +264,21 @@ export const OpenCodeDriver: ProviderDriver Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), ), }); + const loadOpenCode2Models = yield* makeOpenCode2ModelLoader( + openCode2Server.withConnection((connection) => + connection.client.model.list({ location: { directory: serverConfig.cwd } }).pipe( + Effect.map((models) => models.data), + Effect.mapError( + (cause) => + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "model.list", + detail: "The OpenCode server could not list its models.", + cause, + }), + ), + ), + ), + ); const serverOwner = yield* OpenCodeServerOwner.make({ binaryPath: effectiveConfig.binaryPath, directory: serverConfig.cwd, @@ -284,6 +300,7 @@ export const OpenCodeDriver: ProviderDriver effectiveConfig, serverConfig.cwd, runtimeProbe.refresh, + loadOpenCode2Models, ), usageLimits: readOpenCodeGoUsageLimits({ enabled: effectiveConfig.enabled, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 493774d27ba0..a616cf9bc6eb 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -18,6 +18,8 @@ import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; import { checkOpenCodeProviderStatus, + makeOpenCode2ModelLoader, + type OpenCode2Model, openCodeCommandsToServerProviderSlashCommands, } from "./OpenCodeProvider.ts"; import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; @@ -327,6 +329,10 @@ const checkProvider = Effect.fn("checkProvider")(function* ( cwd = process.cwd(), environment?: NodeJS.ProcessEnv, server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword), + openCode2Models: Effect.Effect< + ReadonlyArray, + OpenCodeRuntime.OpenCodeRuntimeError + > = Effect.succeed([]), ) { return yield* Effect.scoped( Effect.gen(function* () { @@ -340,7 +346,7 @@ const checkProvider = Effect.fn("checkProvider")(function* ( Effect.provideService(HttpClient.HttpClient, server), Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), ); - return yield* checkOpenCodeProviderStatus(settings, cwd, probe).pipe( + return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe( Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), ); }), @@ -529,18 +535,63 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); - it.effect("offers a local OpenCode 2 binary in Full access only, never via 1.x", () => + it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () => Effect.gen(function* () { runtimeMock.state.versionStdout = "opencode v2.0.18\n"; - const snapshot = yield* checkProvider(makeOpenCodeSettings()); + const snapshot = yield* checkProvider( + makeOpenCodeSettings(), + process.cwd(), + undefined, + undefined, + Effect.succeed([ + { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] }, + { + providerID: "opencode", + id: "space-bunny-free", + name: "Space Bunny Free", + variants: [{ id: "low" }, { id: "medium" }, { id: "high" }], + }, + ]), + ); NodeAssert.equal(snapshot.status, "ready"); NodeAssert.equal(snapshot.version, "2.0.18"); NodeAssert.deepEqual(snapshot.supportedRuntimeModes, ["full-access"]); + NodeAssert.deepEqual( + snapshot.models.map((model) => model.slug), + ["opencode/big-pickle", "opencode/space-bunny-free"], + ); + const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free") + ?.capabilities?.optionDescriptors?.[0]; + NodeAssert.deepEqual( + variant?.type === "select" ? variant.options.map((option) => option.id) : [], + ["low", "medium", "high"], + ); NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); }), ); + it.effect("reports a failed OpenCode 2 model list without the server's response", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode v2.0.18\n"; + const snapshot = yield* checkProvider( + makeOpenCodeSettings(), + process.cwd(), + undefined, + undefined, + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "model.list", + detail: 'status=500 body={"token":"leaked-response-body"}', + }), + ), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.message, "OpenCode could not load its model list."); + }), + ); + it.effect("uses an environment-only password for local inventory", () => Effect.gen(function* () { yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { @@ -617,7 +668,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), ); - NodeAssert.equal(snapshot.status, "ready"); + NodeAssert.equal(snapshot.status, "warning"); NodeAssert.equal(snapshot.version, "2.0.18"); NodeAssert.deepEqual(snapshot.supportedRuntimeModes, ["full-access"]); NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); @@ -697,3 +748,33 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i }), ); }); + +const bigPickle: OpenCode2Model = { + providerID: "opencode", + id: "big-pickle", + name: "Big Pickle", + variants: [], +}; + +it.effect("waits for a fresh OpenCode 2 server to list its models", () => + Effect.gen(function* () { + // A fresh server lists nothing until its catalog loads. + const replies: Array> = [[], [], [bigPickle]]; + const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle])); + const fiber = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect("keeps the last OpenCode 2 model list while a fresh server's stays empty", () => + Effect.gen(function* () { + let listed: ReadonlyArray = [bigPickle]; + const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed)); + NodeAssert.deepEqual(yield* load, [bigPickle]); + listed = []; + const fiber = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("6 seconds"); + NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); + }).pipe(Effect.provide(TestClock.layer())), +); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index ae7d3704b1a3..202cb75a2ea8 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -9,6 +9,7 @@ import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; import { createModelCapabilities } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; @@ -377,16 +378,126 @@ export const makePendingOpenCodeProvider = ( }); }); +/** One model an OpenCode 2 server lists, as the status check reads it. */ +export interface OpenCode2Model { + readonly providerID: string; + readonly id: string; + readonly name: string; + readonly variants: ReadonlyArray<{ readonly id: string }>; +} + +/** + * A server loads its catalog lazily and lists nothing for its first few + * hundred milliseconds, and a local instance starts a fresh server for each + * status check. So an empty list is read again for a while, and the last + * non-empty list stands in if the catalog still has not loaded. + */ +export const makeOpenCode2ModelLoader = ( + list: Effect.Effect, E>, +) => + Effect.sync(() => { + let lastLoaded: ReadonlyArray = []; + return list.pipe( + Effect.repeat({ + until: (models) => models.length > 0, + schedule: Schedule.spaced("250 millis"), + }), + Effect.timeoutOption("5 seconds"), + Effect.map((loaded) => { + if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value; + return lastLoaded; + }), + ); + }); + /** * OpenCode 2 only runs in Full access until approvals map onto its permission - * rules, so the snapshot offers that mode alone. Its models come from the - * user's custom models until the 2.x model list lands. + * rules, so the snapshot offers that mode alone. */ const OPENCODE_2_PRESENTATION = { ...OPENCODE_PRESENTATION, supportedRuntimeModes: ["full-access"], } as const; +function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities { + const variants = model.variants.map((variant) => variant.id); + const defaultVariant = inferDefaultVariant(model.providerID, variants); + return createModelCapabilities({ + optionDescriptors: + variants.length === 0 + ? [] + : [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: variants.map((id) => ({ + id, + label: titleCaseSlug(id), + ...(id === defaultVariant ? { isDefault: true } : {}), + })), + ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }), + }, + ], + }); +} + +const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( + settings: OpenCodeSettings, + version: string, + checkedAt: string, + loadModels: Effect.Effect, OpenCodeRuntime.OpenCodeRuntimeError>, +) { + const result = yield* Effect.exit(loadModels); + const probe = (status: "ready" | "warning" | "error", message: string) => ({ + installed: true, + version, + status, + auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) }, + message, + }); + if (result._tag === "Failure") { + // The detail can carry a response body; it stays in the log, not the status. + yield* Effect.logWarning("OpenCode 2 model list failed", result.cause); + return buildServerProvider({ + presentation: OPENCODE_2_PRESENTATION, + enabled: true, + checkedAt, + models: providerModelsFromSettings( + [], + settings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: probe("error", "OpenCode could not load its model list."), + }); + } + const models = providerModelsFromSettings( + result.value + .map((model) => ({ + slug: `${model.providerID}/${model.id}`, + name: model.name, + isCustom: false, + capabilities: openCode2ModelCapabilities(model), + })) + .toSorted((left, right) => left.name.localeCompare(right.name)), + settings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + return buildServerProvider({ + presentation: OPENCODE_2_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: + result.value.length > 0 + ? probe( + "ready", + `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`, + ) + : probe("warning", "OpenCode 2 is running, but it did not list any models yet."), + }); +}); + /** * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary, * the version endpoints for a configured server. The status check refreshes it, so an in-place @@ -396,6 +507,10 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu openCodeSettings: OpenCodeSettings, cwd: string, probeRuntime: Effect.Effect, + loadOpenCode2Models: Effect.Effect< + ReadonlyArray, + OpenCodeRuntime.OpenCodeRuntimeError + >, ): Effect.fn.Return< ServerProviderDraft, never, @@ -465,19 +580,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); const probed = probedExit.value; if (probed.generation === "v2") { - return buildServerProvider({ - presentation: OPENCODE_2_PRESENTATION, - enabled: true, - checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: true, - version: probed.version, - status: "ready", - auth: { status: "unknown" }, - message: `OpenCode ${probed.version} is available.`, - }, - }); + return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models); } let version: string | null = probed.version; if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) { From 8517e59bd832296b68a2907c87c5def9c3f9250c Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:29:44 -0700 Subject: [PATCH 26/34] feat(server): OpenCode 2 approvals, questions and every runtime mode Carries pingdotgg/t3code#14407 oc2-5-approvals unchanged, squashed to one commit with Julius Marminge's authorship. Drop this layer once it merges into codex-turn-mapping. --- .../Adapters/OpenCode2AdapterV2.test.ts | 428 +++++++-- .../Adapters/OpenCode2AdapterV2.testkit.ts | 4 + .../Adapters/OpenCode2AdapterV2.ts | 906 +++++++++++++++--- ...penCode2OrchestratorV2.integration.test.ts | 198 +++- .../OpenCode2OrchestratorV2.live.test.ts | 162 +++- .../testkit/fixtures/index.ts | 37 + .../fixtures/opencode2_permission/input.ts | 15 + .../opencode_transcript.ndjson | 115 +++ .../fixtures/opencode2_permission/output.ts | 81 ++ .../fixtures/opencode2_question/input.ts | 11 + .../opencode_transcript.ndjson | 40 + .../fixtures/opencode2_question/output.ts | 59 ++ .../testkit/fixtures/shared.ts | 18 +- .../provider/Layers/OpenCodeProvider.test.ts | 14 +- .../src/provider/Layers/OpenCodeProvider.ts | 6 +- 15 files changed, 1827 insertions(+), 267 deletions(-) create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission/opencode_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/opencode_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/output.ts diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 3fe4940fe951..6d70c1cdadd7 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -34,7 +34,7 @@ import type { } from "../ProviderAdapter.ts"; import * as IdAllocator from "../IdAllocator.ts"; import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; -import { OPENCODE_2_FULL_ACCESS_ONLY, OPENCODE_2_STILL_STOPPING } from "./OpenCode2AdapterV2.ts"; +import { OPENCODE_2_STILL_STOPPING } from "./OpenCode2AdapterV2.ts"; import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; @@ -110,6 +110,14 @@ const promptAccepted = replyData("session.prompt", { delivery: "steer", }); +/** A resumed session that waits on nothing. */ +const noOpenRequests: ReadonlyArray = [ + out("permission.list", { sessionID: SESSION }), + replyData("permission.list", []), + out("session.form.list", { sessionID: SESSION }), + replyData("session.form.list", []), +]; + /** What every session sends when it opens: the event stream, then the model list. */ const opening: ReadonlyArray = [ out("event.subscribe"), @@ -168,27 +176,113 @@ const turnInput = ( runtimePolicy: policy(runtimeMode), }); -/** Resumes the recorded session and returns the runtime, the thread, and its event stream. */ -const resumed = (entries: ReadonlyArray, options?: { external?: boolean }) => +/** The agents' own path rules, as `/api/agent` lists them for build. */ +const buildPaths = [ + { + action: "external_directory", + resource: "/home/.local/share/opencode/tool-output/*", + effect: "allow", + }, +]; +const agentList = { + location: { directory: WORK }, + data: [ + { + id: "build", + name: "Build", + request: { settings: {}, headers: {}, body: {} }, + description: "The default agent.", + mode: "primary", + hidden: false, + permissions: [{ action: "*", resource: "*", effect: "allow" }, ...buildPaths], + }, + ], +}; +/** Supervised: shell, edits and other directories ask. */ +const supervisedRules = [ + { action: "shell", resource: "*", effect: "ask" }, + { action: "edit", resource: "*", effect: "ask" }, + { action: "external_directory", resource: "*", effect: "ask" }, + ...buildPaths, + { action: "subagent", resource: "*", effect: "deny" }, +]; + +// The first ask and question form the spike recorded (recordings/permission, question). +const shellAsk = { + data: { + id: "per_0eb7c4d7e001Pyt8o50Vi4KrOO", + sessionID: SESSION, + action: "shell", + resources: ["echo FIRST"], + save: ["echo *"], + source: { type: "tool", messageID: "msg_0eb7c4330001dYFQKuTpfD780v", id: "call_1" }, + }, +}; +const shellAskEvent = event("permission.asked", shellAsk.data); +const colorForm = { + id: "frm_0eb79ab35001fkvFECSh3wYNVD", + sessionID: SESSION, + title: "Questions", + metadata: { kind: "question" }, + fields: [ + { + key: "q0", + title: "Color preference", + description: "Which color do you prefer?", + type: "string", + options: [{ value: "Red", label: "Red" }], + custom: true, + }, + ], +}; + +/** + * Resumes the recorded session and returns the runtime and the thread. A + * supervised resume gives the session Supervised rules first. + */ +const resumed = ( + entries: ReadonlyArray, + options?: { readonly external?: boolean; readonly supervised?: boolean }, +) => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime( [ ...opening, out("session.get", { sessionID: SESSION }), replyData("session.get", sessionInfo()), + ...noOpenRequests, + ...(options?.supervised === true + ? [ + out("agent.list", ""), + reply("agent.list", agentList), + out("session.update", { sessionID: SESSION, permissions: supervisedRules }), + reply("session.update", null), + ] + : []), ...entries, ], - options, + options?.external === undefined ? undefined : { external: options.external }, ); const thread = yield* runtime.resumeThread({ providerThread: providerThread(yield* DateTime.now), threadId, modelSelection: bigPickle, - runtimePolicy: policy(), + runtimePolicy: policy(options?.supervised === true ? "approval-required" : "full-access"), }); return { runtime, thread }; }); +const requestOf = (runtime: ProviderAdapterV2SessionRuntime) => + runtime.events.pipe( + Stream.filter( + (event): event is Extract => + event.type === "runtime_request.updated", + ), + Stream.map((event) => event.runtimeRequest), + Stream.runHead, + Effect.map(Option.getOrUndefined), + ); + const terminalOf = (runtime: ProviderAdapterV2SessionRuntime) => runtime.events.pipe( Stream.filter( @@ -257,22 +351,6 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); - it.effect( - "refuses a turn outside Full access instead of running it with every tool allowed", - () => - Effect.gen(function* () { - // No prompt is expected: the turn fails without reaching the server. - const { runtime, thread } = yield* resumed([]); - const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required")); - const refused = yield* Fiber.join(terminal); - assert.deepInclude(refused?.failure, { - class: "validation_error", - message: OPENCODE_2_FULL_ACCESS_ONLY, - }); - }).pipe(Effect.scoped), - ); - it.effect("ends the turn when its terminal event is one this build cannot decode", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -651,6 +729,7 @@ describe("OpenCode2 adapter", () => { "session.get", sessionInfo({ permissions: [{ action: "*", resource: "*", effect: "allow" }] }), ), + ...noOpenRequests, out("session.update", { sessionID: SESSION, permissions: t3Rules }), reply("session.update", null), ]); @@ -669,6 +748,7 @@ describe("OpenCode2 adapter", () => { ...opening, out("session.get", { sessionID: SESSION }), replyData("session.get", sessionInfo()), + ...noOpenRequests, out("session.move", { sessionID: SESSION, directory: "/work/opencode2-feature" }), reply("session.move", null), ]); @@ -689,6 +769,7 @@ describe("OpenCode2 adapter", () => { ...opening, out("session.get", { sessionID: SESSION }), replyData("session.get", sessionInfo()), + ...noOpenRequests, out("session.move", { sessionID: SESSION, directory: "/work/opencode2-feature" }), reply("session.move", null), ]); @@ -753,7 +834,51 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); - it.effect("ends the turn when a permission it refuses cannot be answered", () => + it.effect("stops the requests a session still waits on when a restarted T3 loads it", () => + Effect.gen(function* () { + // T3 restarted while the server kept waiting on an ask T3 no longer shows. + const runtime = yield* openCode2ReplayRuntime( + [ + ...opening, + out("session.get", { sessionID: SESSION }), + replyData("session.get", sessionInfo()), + out("permission.list", { sessionID: SESSION }), + replyData("permission.list", [shellAsk.data]), + out("session.form.list", { sessionID: SESSION }), + replyData("session.form.list", [colorForm]), + out("permission.reply", { + sessionID: SESSION, + requestID: shellAsk.data.id, + decision: "reject", + }), + reply("permission.reply", null), + out("session.form.cancel", { sessionID: SESSION, formID: colorForm.id }), + reply("session.form.cancel", null), + // The next turn checks that the stopped run is gone. Its end arrives + // late and is its own; the turn ends on its own execution's end. + out("session.active"), + reply("session.active", { data: {} }), + event("session.execution.interrupted", { sessionID: SESSION, reason: "user" }), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { external: true }, + ); + const thread = yield* runtime.resumeThread({ + providerThread: providerThread(yield* DateTime.now), + threadId, + modelSelection: bigPickle, + runtimePolicy: policy(), + }); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + }).pipe(Effect.scoped), + ); + + it.effect("ends the turn when an answer to its request cannot be delivered", () => Effect.gen(function* () { const failedReply = reply("permission.reply", { status: 500, @@ -761,98 +886,226 @@ describe("OpenCode2 adapter", () => { }); const replyOut = out("permission.reply", { sessionID: SESSION, - requestID: "per_0eb7c4d7e001Pyt8o50Vi4KrOO", - decision: "reject", - message: "", + requestID: shellAsk.data.id, + decision: "once", }); - const { runtime, thread } = yield* resumed([ - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - { - type: "emit_inbound", - frame: { - type: "sdk.event", - event: { - id: "evt_permissionasked0", - created: 1, - type: "permission.asked", - data: { - id: "per_0eb7c4d7e001Pyt8o50Vi4KrOO", - sessionID: SESSION, - action: "shell", - resources: ["echo FIRST"], - }, - }, - }, - }, - // One try and one retry, then the turn ends and the session is stopped. - replyOut, - failedReply, - replyOut, - failedReply, - out("session.interrupt", { sessionID: SESSION }), - reply("session.interrupt", { interrupted: true }), - ]); + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + shellAskEvent, + // One try and one retry, then the turn ends and the session is stopped. + replyOut, + failedReply, + replyOut, + failedReply, + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + ], + { supervised: true }, + ); + const requested = yield* requestOf(runtime).pipe(Effect.forkScoped); const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn(turnInput(thread)); + yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required")); + const request = yield* Fiber.join(requested); + yield* runtime.respondToRuntimeRequest({ requestId: request!.id, decision: "accept" }); const ended = yield* Fiber.join(terminal); assert.equal(ended?.status, "failed"); assert.equal( ended?.status === "failed" ? ended.failure.message : undefined, "OpenCode is waiting on a request T3 Code couldn't answer.", ); - // Let the best-effort interrupt reach the server before the scope closes. - for (let i = 0; i < 20; i++) yield* Effect.yieldNow; }).pipe(Effect.scoped), ); - it.effect("answers a question form instead of cancelling it", () => + it.effect("counts an answer to a request OpenCode already dropped as delivered", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + shellAskEvent, + // The execution ended first, so OpenCode no longer knows the request. + out("permission.reply", { + sessionID: SESSION, + requestID: shellAsk.data.id, + decision: "once", + }), + reply("permission.reply", { + status: 404, + body: { + _tag: "PermissionNotFoundError", + requestID: shellAsk.data.id, + message: "Permission request not found", + }, + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { supervised: true }, + ); + const requested = yield* requestOf(runtime).pipe(Effect.forkScoped); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required")); + const request = yield* Fiber.join(requested); + yield* runtime.respondToRuntimeRequest({ requestId: request!.id, decision: "accept" }); + // Not "waiting on a request T3 Code couldn't answer": nothing waits on it. + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + }).pipe(Effect.scoped), + ); + + it.effect("declines a form T3 cannot show with the reason, instead of leaving it open", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ out("session.prompt", { sessionID: SESSION, text: "" }), promptAccepted, - { - type: "emit_inbound", - frame: { - type: "sdk.event", - event: { - id: "evt_formcreated0000", - created: 1, - type: "form.created", - data: { - form: { - id: "frm_0eb79ab35001fkvFECSh3wYNVD", - sessionID: SESSION, - title: "Questions", - metadata: { kind: "question" }, - fields: [ - { - key: "q0", - title: "Color preference", - type: "string", - options: [{ value: "Red", label: "Red" }], - custom: true, - }, - ], - }, + event("form.created", { + form: { + id: "frm_0eb79ab35001fkvFECSh3wYNVD", + sessionID: SESSION, + title: "MCP authorization", + metadata: { kind: "mcp" }, + fields: [ + { + key: "authorization", + type: "external", + url: "https://example.com/authorize", + title: "Authorize", }, - }, + ], }, - }, - out("session.form.reply", { + }), + out("session.form.cancel", { sessionID: SESSION, formID: "frm_0eb79ab35001fkvFECSh3wYNVD", - answer: { q0: "Questions aren't supported by this OpenCode integration yet." }, }), - reply("session.form.reply", null), - event("session.execution.succeeded", { sessionID: SESSION }), + reply("session.form.cancel", null), ]); const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); yield* runtime.startTurn(turnInput(thread)); + const ended = yield* Fiber.join(terminal); + assert.equal(ended?.status, "failed"); + assert.include( + ended?.status === "failed" ? ended.failure.message : "", + "asked for a link to open", + ); + }).pipe(Effect.scoped), + ); + + it.effect("asks a subagent's permission request on the parent thread's turn", () => + Effect.gen(function* () { + const child = "ses_f0e5aa64cffelFnoQRL0DAA9BH"; + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + // As 2.0.18 announced the child the live probe's parent started. + event("session.created", { + sessionID: child, + slug: "stellar-garden", + version: "2.0.18", + projectID: "global", + parentID: SESSION, + location: { directory: WORK }, + subpath: "", + title: "Echo test command", + agent: "general", + permissions: supervisedRules, + model: { id: "big-pickle", providerID: "opencode", variant: "default" }, + }), + event("permission.asked", { ...shellAsk.data, sessionID: child }), + out("permission.reply", { + sessionID: child, + requestID: shellAsk.data.id, + decision: "once", + }), + reply("permission.reply", null), + event("permission.replied", { + sessionID: child, + requestID: shellAsk.data.id, + reply: "once", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { supervised: true }, + ); + const requested = yield* requestOf(runtime).pipe(Effect.forkScoped); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required")); + const request = yield* Fiber.join(requested); + assert.equal(request?.nativeRequestRef?.nativeId, shellAsk.data.id); + yield* runtime.respondToRuntimeRequest({ requestId: request!.id, decision: "accept" }); assert.equal((yield* Fiber.join(terminal))?.status, "completed"); }).pipe(Effect.scoped), ); + it.effect("keeps 'allow this session' in the session's rules, not OpenCode's saved grants", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + shellAskEvent, + // The grant goes into this session's rules; the reply is only `once`. + out("session.update", { + sessionID: SESSION, + permissions: [ + ...supervisedRules.slice(0, 3), + { action: "shell", resource: "echo *", effect: "allow" }, + ...supervisedRules.slice(3), + ], + }), + reply("session.update", null), + out("permission.reply", { + sessionID: SESSION, + requestID: shellAsk.data.id, + decision: "once", + }), + reply("permission.reply", null), + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { supervised: true }, + ); + const requested = yield* requestOf(runtime).pipe(Effect.forkScoped); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required")); + const request = yield* Fiber.join(requested); + yield* runtime.respondToRuntimeRequest({ + requestId: request!.id, + decision: "acceptForSession", + }); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + }).pipe(Effect.scoped), + ); + + it.effect("reads the agents again while a fresh server still lists none", () => + Effect.gen(function* () { + // 2.0.18 answers `/api/agent` with `[]` for a moment after it starts; + // rules written from that would drop the agents' own paths. + const runtime = yield* openCode2ReplayRuntime([ + ...opening, + out("agent.list", ""), + reply("agent.list", { location: { directory: WORK }, data: [] }), + out("agent.list", ""), + reply("agent.list", agentList), + out("session.create", { + location: { directory: WORK }, + model: { providerID: "opencode", id: "big-pickle" }, + permissions: supervisedRules, + }), + replyData("session.create", sessionInfo({ permissions: supervisedRules })), + ]); + const created = yield* runtime + .ensureThread({ + threadId, + modelSelection: bigPickle, + runtimePolicy: policy("approval-required"), + }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust("250 millis"); + assert.equal((yield* Fiber.join(created)).nativeThreadRef?.nativeId, SESSION); + }).pipe(Effect.scoped), + ); + it.effect("refuses a turn after the event stream is gone instead of prompting", () => Effect.gen(function* () { // The stream ends with no turn running; no prompt is expected after it. @@ -917,6 +1170,7 @@ describe("OpenCode2 adapter", () => { reply("model.list", { location: { directory: WORK }, data: [] }), out("session.get", { sessionID: SESSION }), replyData("session.get", sessionInfo()), + ...noOpenRequests, out("session.prompt", { sessionID: SESSION, text: "" }), promptAccepted, event("session.execution.succeeded", { sessionID: SESSION }), diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index 5f6a272bc3e9..c92db1d577c6 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -62,6 +62,7 @@ const operationOf = ( const session = /^\/api\/session\/([^/]+)(\/.*)?$/.exec(path); if (method === "GET" && path === "/api/event") return { type: "event.subscribe" }; if (method === "GET" && path === "/api/model") return { type: "model.list", input: query }; + if (method === "GET" && path === "/api/agent") return { type: "agent.list", input: query }; if (method === "POST" && path === "/api/session") return { type: "session.create", input: body }; if (method === "GET" && path === "/api/session/active") return { type: "session.active" }; if (session !== null) { @@ -73,7 +74,10 @@ const operationOf = ( if (method === "POST" && rest === "/interrupt") return { type: "session.interrupt", input }; if (method === "POST" && rest === "/model") return { type: "session.switchModel", input }; if (method === "POST" && rest === "/move") return { type: "session.move", input }; + if (method === "POST" && rest === "/synthetic") return { type: "session.synthetic", input }; if (method === "GET" && rest === "/message") return { type: "message.list", input }; + if (method === "GET" && rest === "/permission") return { type: "permission.list", input }; + if (method === "GET" && rest === "/form") return { type: "session.form.list", input }; const form = /^\/form\/([^/]+)(\/reply)?$/.exec(rest); if (form !== null) { const formInput = { ...input, formID: form[1] }; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index fd2068fa10ea..ab8a3621a326 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -4,18 +4,20 @@ * server's `/api/event` stream, routed here by session id. * * A turn is one `session.prompt`; the session's next `session.execution.*` - * terminal ends it. Approvals, questions, subagents, steering, fork, rollback - * and compaction arrive in later layers: sessions run in Full access only, - * with the `subagent` tool denied, the capabilities below say no, and a - * permission or form that still reaches a session is answered so the turn - * cannot hang. + * terminal ends it. Each runtime mode is a set of session permission rules, + * and OpenCode's permission asks and question forms become runtime requests + * on the asking session's thread. Subagents, steering, fork, rollback and + * compaction arrive in later layers: the `subagent` tool is denied and the + * capabilities below say no. * * @module orchestration-v2/Adapters/OpenCode2AdapterV2 */ import { AbsolutePath, + Form, Location, Model, + Permission, Provider, Session, type OpenCodeEvent, @@ -27,8 +29,12 @@ import type { OrchestrationV2ProviderSession, OrchestrationV2ProviderThread, OrchestrationV2ProviderTurn, + OrchestrationV2RuntimeRequest, OrchestrationV2TurnItem, + OrchestrationV2UserInputQuestion, + ProviderApprovalDecision, ProviderInstanceId, + RuntimeRequestId, } from "@t3tools/contracts"; import type * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; @@ -36,6 +42,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Queue from "effect/Queue"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -48,13 +55,14 @@ import { } from "../../provider/opencodeRuntime.ts"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { causeErrorTag } from "@t3tools/shared/observability"; import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; import * as IdAllocator from "../IdAllocator.ts"; import { makeProviderFailure } from "../ProviderFailure.ts"; import * as ProviderAdapter from "../ProviderAdapter.ts"; import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; -import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; +import { OPENCODE_PROVIDER, openCodePermissionRequestKind } from "./OpenCodeAdapterV2.ts"; import { openCodeToolTurnItem } from "./OpenCodeToolItems.ts"; const OpenCode2ProviderCapabilities = { @@ -63,7 +71,8 @@ const OpenCode2ProviderCapabilities = { supportsMultipleProviderThreadsPerSession: true, supportsModelSwitchInSession: true, supportsProviderSwitchingViaHandoff: true, - supportsRuntimeModeSwitchInSession: false, + // A mode change rewrites the session's rules when its next turn resumes it. + supportsRuntimeModeSwitchInSession: true, pendingRequestsSurviveRestart: false, }, threads: { @@ -101,11 +110,11 @@ const OpenCode2ProviderCapabilities = { supportsDynamicToolCallbacks: false, }, approvals: { - supportsCommandApproval: false, - supportsFileReadApproval: false, - supportsFileChangeApproval: false, - supportsApplyPatchApproval: false, - approvalsHaveNativeRequestIds: false, + supportsCommandApproval: true, + supportsFileReadApproval: true, + supportsFileChangeApproval: true, + supportsApplyPatchApproval: true, + approvalsHaveNativeRequestIds: true, approvalCallbacksAreLiveOnly: true, approvalsCanOriginateFromSubagents: false, }, @@ -113,7 +122,7 @@ const OpenCode2ProviderCapabilities = { emitsPlanUpdated: false, emitsTodoList: false, emitsProposedPlan: false, - supportsStructuredQuestions: false, + supportsStructuredQuestions: true, planDeltasHaveItemIds: false, }, subagents: { @@ -145,9 +154,9 @@ const OpenCode2ProviderCapabilities = { nativeThreadIds: "strong", nativeTurnIds: "weak", nativeItemIds: "strong", - nativeRequestIds: "none", + nativeRequestIds: "strong", }, - // Sessions run with every tool allowed; the snapshot offers only Full access. + // OpenCode enforces each runtime mode through the session's permission rules. runtimePolicy: { enforcement: "native" }, } satisfies OrchestrationV2ProviderCapabilities; @@ -190,6 +199,7 @@ interface OpenBlock { } interface ThreadState { + readonly sessionId: string; providerThread: OrchestrationV2ProviderThread; readonly providerTurns: Map; active: ActiveTurn | undefined; @@ -197,13 +207,187 @@ interface ThreadState { model: ModelRef | undefined; /** * Set when a turn ended here while OpenCode may still be running it: a Stop - * that timed out, or a prompt whose request failed without a clear answer. - * Execution events carry only the session id, so the next execution end - * belongs to that run; it clears this and ends no turn. + * that timed out, a prompt whose request failed without a clear answer, or a + * request T3 could not answer. Execution events carry only the session id, + * so the next execution end belongs to that run; it clears this and ends no turn. */ unsettled: boolean; + /** The session's location, where its agents' path rules are read. */ + directory: string; + /** The agent the session runs; its own path rules stay in force. */ + agent: string; + /** The native session's rules as T3 last read or wrote them, and the policy they are for. */ + rules: ReadonlyArray | undefined; + policy: RulesPolicy; + /** "Allow … this session" answers, kept in the session's rules while T3 has it open. */ + readonly grants: Array; } +type Rule = Permission.Rule; +type RulesPolicy = Pick< + ProviderAdapter.ProviderAdapterV2RuntimePolicy, + "runtimeMode" | "interactionMode" +>; +type NativeForm = EventOf<"form.created">["data"]["form"]; + +/** A permission ask or question form shown to the user and not answered yet. */ +interface PendingRequest { + readonly request: OrchestrationV2RuntimeRequest; + readonly item: OrchestrationV2TurnItem; + readonly node: OrchestrationV2ExecutionNode; + readonly state: ThreadState; + readonly turn: ActiveTurn; + /** The session that asked: the thread's own, or one of its subagents'. */ + readonly sessionId: string; + /** Set once T3 sends its answer; the orchestrator has already recorded it. */ + answering: boolean; + readonly native: + | { + readonly type: "permission"; + readonly id: string; + readonly action: string; + readonly resources: ReadonlyArray; + readonly save: ReadonlyArray; + } + | { readonly type: "form"; readonly id: string; readonly form: NativeForm }; +} + +const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resource: "*", effect }); + +/** + * A session's permission rules. OpenCode checks the agent's rules and then + * these, and the last rule that matches decides, so these override the + * agent's. `paths` are the agent's own allows for its directories (saved tool + * output, the plan agent's plan directory), which the blanket rules here would + * otherwise override; `grants` are "Always allow this session" answers. + */ +const sessionRules = ( + policy: RulesPolicy, + paths: ReadonlyArray, + grants: ReadonlyArray, +): ReadonlyArray => [ + ...(policy.runtimeMode === "full-access" + ? [rule("*", "allow")] + : [ + rule("shell", "ask"), + rule("edit", policy.runtimeMode === "auto-accept-edits" ? "allow" : "ask"), + rule("external_directory", "ask"), + ]), + ...grants, + // Plan mode writes only its plan, which `paths` allows again. Shell and read + // are never denied: the free tier refuses sessions whose rules deny them. + ...(policy.interactionMode === "plan" ? [rule("edit", "deny")] : []), + ...paths, + // A background child wakes its parent in a turn T3 would not see. + rule("subagent", "deny"), +]; + +const sameRules = (left: ReadonlyArray | undefined, right: ReadonlyArray) => + left?.length === right.length && + left.every( + (entry, index) => + entry.action === right[index]?.action && + entry.resource === right[index]?.resource && + entry.effect === right[index]?.effect, + ); + +/** + * Sent with a declined permission. A reject without a message is OpenCode's + * "stop": it ends the whole execution, which is Cancel. + */ +const DECLINED = "The user declined this request."; + +/** + * Steered into the session before a decline: a declined shell call reaches the + * model only as "Unable to execute command", which it retries. + */ +const declinedNote = (action: string, resources: ReadonlyArray) => + `The user declined the ${action} request${resources.length === 0 ? "" : ` (${resources.join(", ")})`}. Do not retry it; continue without it or ask the user how to proceed.`; + +/** The session-wide choice for a request whose `save` patterns OpenCode would remember. */ +const sessionGrantLabel = (action: string, save: ReadonlyArray) => + save.every((pattern) => pattern === "*") + ? `Allow every ${action} request this session` + : `Allow ${save.join(", ")} this session`; + +const text = (value: string | undefined, fallback: string) => value?.trim() || fallback; + +/** + * A form's fields as T3 questions, or why T3 cannot ask them: a link to open, + * a field shown only for another answer, a hidden field, or a number or yes/no + * value. OpenCode's question tool only asks text and multi-select fields. + */ +const formQuestions = ( + form: NativeForm, +): + | { readonly questions: ReadonlyArray } + | { readonly unsupported: string } => { + const questions: Array = []; + for (const [index, field] of form.fields.entries()) { + if (field.type === "external") return { unsupported: "a link to open" }; + if (field.type !== "string" && field.type !== "multiselect") { + return { unsupported: `a ${field.type} value` }; + } + if (field.hidden === true) return { unsupported: "a hidden field" }; + if ((field.when?.length ?? 0) > 0) return { unsupported: "a field that depends on another" }; + const header = text(field.title, `Question ${index + 1}`); + const options = (field.options ?? []).map((option) => { + const label = text(option.label, text(option.value, "Option")); + return { label, description: text(option.description, label), value: option.value }; + }); + questions.push({ + id: field.key, + header, + question: text(field.description, header), + options, + multiSelect: field.type === "multiselect", + allowCustomAnswer: field.custom === true || options.length === 0, + }); + } + return { questions }; +}; + +/** T3's answers in OpenCode's shape: a list for a multi-select, text otherwise. */ +const formAnswer = (form: NativeForm, answers: Readonly>) => { + const answer: Record> = {}; + for (const field of form.fields) { + const raw = answers[field.key]; + const values = (Array.isArray(raw) ? raw : [raw]).filter( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ); + if (values.length > 0) { + answer[field.key] = field.type === "multiselect" ? values : values.join(", "); + } + } + return answer; +}; + +const approves = (decision: ProviderApprovalDecision) => + decision === "accept" || decision === "acceptForSession" || decision === "acceptAlways"; + +/** + * An answer to a request OpenCode already dropped (its execution ended, or + * its session is gone): nothing waits on it, so it counts as delivered. + */ +const permissionGone = { + PermissionNotFoundError: () => Effect.void, + SessionNotFoundError: () => Effect.void, +}; +const formGone = { + FormNotFoundError: () => Effect.void, + FormAlreadySettledError: () => Effect.void, + SessionNotFoundError: () => Effect.void, +}; + +/** The session rules an agent keeps for its own directories, which T3's blanket rules would override. */ +const agentPaths = (rules: ReadonlyArray) => + rules.filter( + (entry) => + entry.effect === "allow" && + entry.resource !== "*" && + (entry.action === "edit" || entry.action === "external_directory"), + ); + /** One wording for every capability later layers add. */ const notYet = (feature: string) => new ProviderAdapter.ProviderAdapterProtocolError({ @@ -232,35 +416,6 @@ const sessionIdOf = (providerThread: OrchestrationV2ProviderThread) => { const textOf = (content: ReadonlyArray<{ readonly type: string; readonly text?: string }>) => content.flatMap((part) => (part.type === "text" && part.text ? [part.text] : [])).join("\n"); -/** - * Every tool runs without asking, except `subagent`: a background child wakes - * its parent in a turn T3 would not see. Both go when approvals and subagents land. - */ -const SESSION_PERMISSIONS = [ - { action: "*", resource: "*", effect: "allow" }, - { action: "subagent", resource: "*", effect: "deny" }, -] as const; - -const sameRules = ( - left: - | ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: string }> - | undefined, - right: typeof SESSION_PERMISSIONS, -) => - left?.length === right.length && - left.every( - (rule, index) => - rule.action === right[index]?.action && - rule.resource === right[index]?.resource && - rule.effect === right[index]?.effect, - ); - -export const OPENCODE_2_FULL_ACCESS_ONLY = - "OpenCode 2 needs Full access for now; approvals come in a later update. Switch this thread's mode to continue."; - -// Questions are answered, not cancelled: a cancelled form ends the execution as a user stop. -const QUESTION_REPLY = "Questions aren't supported by this OpenCode integration yet."; - const INTERRUPT_TIMEOUT = "10 seconds"; const ACTIVE_CHECK_TIMEOUT = "5 seconds"; /** Answers that mean the server refused a prompt; any other failure may have been accepted. */ @@ -362,8 +517,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }; const events = yield* Queue.unbounded(); const threads = new Map(); + // A subagent's session, by its id, to the thread whose session started it. + const childOwners = new Map(); + const pending = new Map(); const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); + const ownerOf = (sessionId: string) => threads.get(sessionId) ?? childOwners.get(sessionId); const setSessionStatus = ( status: OrchestrationV2ProviderSession["status"], @@ -560,6 +719,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const turn = state.active; if (turn === undefined) return; state.active = undefined; + // OpenCode drops a request when its execution ends; so does the turn. + for (const entry of pending.values()) { + if (entry.turn === turn) yield* settleRequest(entry, "cancelled"); + } const completedAt = yield* DateTime.now; // Blocks still open when the execution ends are final as they stand. for (const open of turn.texts.values()) { @@ -606,7 +769,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr providerThread: state.providerThread, }); const anyActive = [...threads.values()].some((candidate) => candidate.active !== undefined); - yield* setSessionStatus(anyActive ? "running" : "ready", null); + yield* setSessionStatus(pending.size > 0 ? "waiting" : anyActive ? "running" : "ready", null); const base = { type: "turn.terminal" as const, driver, @@ -631,53 +794,301 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); /** - * Answers a permission or form T3 cannot show yet, so a session never waits - * on it. An answer that cannot be delivered leaves OpenCode paused, so the - * turn it would block ends as failed and the session is stopped instead. + * Ends the turn a request T3 could not answer would block, and stops the + * session: OpenCode waits on an unanswered request forever. The stopped + * run's end is its own, not the next turn's. + */ + const abandonRequest = Effect.fnUntraced(function* (state: ThreadState, reason: string) { + yield* Effect.logWarning("Could not answer an OpenCode request; ending its turn.", { + reason, + }); + if (state.active === undefined) return; + state.unsettled = true; + yield* finishTurn(state, { + status: "failed", + failure: makeProviderFailure({ + message: "OpenCode is waiting on a request T3 Code couldn't answer.", + class: "provider_error", + }), + }); + yield* client.session + .interrupt({ sessionID: Session.ID.make(state.sessionId) }) + .pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true })); + }); + + /** Stops tracking a request; the session is waiting only while any remain. */ + const forgetRequest = Effect.fnUntraced(function* (entry: PendingRequest) { + if (!pending.delete(entry.request.id)) return false; + if (pending.size === 0 && session.status === "waiting") { + yield* setSessionStatus("running", null); + } + return true; + }); + + /** + * Settles a request OpenCode answered or dropped on its own. T3's own + * answers are only forgotten: the orchestrator already recorded them. */ - const refuseRequest = Effect.fnUntraced(function* ( - event: EventOf<"permission.asked"> | EventOf<"form.created">, + const settleRequest = Effect.fnUntraced(function* ( + entry: PendingRequest, + status: "resolved" | "cancelled", ) { - const sessionId = - event.type === "permission.asked" ? event.data.sessionID : event.data.form.sessionID; - yield* Effect.logWarning("Answered an OpenCode request this runtime cannot show yet.", { - type: event.type, + if (!(yield* forgetRequest(entry)) || entry.answering) return; + const now = yield* DateTime.now; + const itemStatus = status === "resolved" ? "completed" : "cancelled"; + yield* emit({ + type: "runtime_request.updated", + driver, + threadId: entry.turn.input.threadId, + runtimeRequest: { ...entry.request, status, resolvedAt: now }, + }); + yield* emit({ + type: "node.updated", + driver, + node: { ...entry.node, status: itemStatus, completedAt: now }, + }); + yield* emit({ + type: "turn_item.updated", + driver, + turnItem: { ...entry.item, status: itemStatus, completedAt: now, updatedAt: now }, }); - const delivered = yield* event.type === "permission.asked" - ? deliver( - client.permission.reply({ - sessionID: event.data.sessionID, - requestID: event.data.id, + }); + + /** + * Stops a run no turn of T3's waits on (one a Stop left running, or one + * found asking after a reconnect): a reject without a message and a + * cancelled form both end OpenCode's execution. Its end is not a turn's. + */ + const stopStaleRequest = ( + sessionId: string, + native: { readonly type: "permission" | "form"; readonly id: string }, + ): Effect.Effect => + native.type === "permission" + ? client.permission + .reply({ + sessionID: Session.ID.make(sessionId), + requestID: Permission.ID.make(native.id), decision: "reject", - message: "T3 Code cannot answer this request for OpenCode 2 yet.", - }), - ) - : deliver( - client.session.form.reply({ - sessionID: event.data.form.sessionID, - formID: event.data.form.id, - answer: Object.fromEntries( - event.data.form.fields.map((field) => [field.key, QUESTION_REPLY]), - ), - }), - ); - if (delivered) return; - yield* Effect.logWarning("Could not answer an OpenCode request; ending its turn.", { - type: event.type, + }) + .pipe( + Effect.catchTags(permissionGone), + Effect.timeout(REQUEST_REPLY_TIMEOUT), + Effect.ignore({ log: true }), + ) + : client.session.form + .cancel({ sessionID: sessionId, formID: Form.ID.make(native.id) }) + .pipe( + Effect.catchTags(formGone), + Effect.timeout(REQUEST_REPLY_TIMEOUT), + Effect.ignore({ log: true }), + ); + + /** + * Stops what a session still waits on when this runtime first loads it: + * T3 shows none of those requests (a restart or a closed session expired + * them), and OpenCode would wait on them forever. + */ + const stopLeftoverRequests = Effect.fnUntraced(function* (state: ThreadState) { + const sessionID = Session.ID.make(state.sessionId); + const listed = yield* Effect.all([ + client.permission.list({ sessionID }), + client.session.form.list({ sessionID }), + ]).pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT), Effect.option); + if (listed._tag === "None") { + return yield* Effect.logWarning("Could not list an OpenCode session's open requests."); + } + const [permissions, forms] = listed.value; + if (permissions.length === 0 && forms.length === 0) return; + // The stopped run's end is not the next turn's. + state.unsettled = true; + for (const request of permissions) { + yield* stopStaleRequest(state.sessionId, { type: "permission", id: request.id }); + } + for (const form of forms) { + yield* stopStaleRequest(state.sessionId, { type: "form", id: form.id }); + } + }); + + /** + * Shows a permission ask or question form on the thread whose session (or + * subagent session) asked, under that thread's running turn. A request no + * turn of T3's is waiting on is left for OpenCode's own clients. + */ + const showRequest = Effect.fnUntraced(function* ( + sessionId: string, + native: PendingRequest["native"], + body: + | { + readonly type: "approval_request"; + readonly requestKind: Extract< + OrchestrationV2TurnItem, + { type: "approval_request" } + >["requestKind"]; + readonly prompt: string; + readonly options: Extract< + OrchestrationV2TurnItem, + { type: "approval_request" } + >["options"]; + } + | { + readonly type: "user_input_request"; + readonly questions: ReadonlyArray; + }, + ) { + const state = ownerOf(sessionId); + const turn = state?.active; + if (state === undefined || turn === undefined) return; + if ([...pending.values()].some((entry) => entry.native.id === native.id)) return; + const now = yield* DateTime.now; + const requestId = yield* idAllocator.allocate.runtimeRequest({ + driver, + providerTurnId: turn.providerTurn.id, + nativeRequestId: native.id, }); - const state = threads.get(sessionId); - if (state !== undefined) { - yield* finishTurn(state, { - status: "failed", - failure: makeProviderFailure({ - message: "OpenCode is waiting on a request T3 Code couldn't answer.", - class: "provider_error", - }), - }); + const nodeId = idAllocator.derive.approvalNode({ requestId }); + const request: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId: turn.providerTurn.id, + nativeRequestRef: ref(native.id), + kind: body.type === "approval_request" ? body.requestKind : "user_input", + status: "pending", + responseCapability: { type: "live", providerSessionId: input.providerSessionId }, + createdAt: now, + resolvedAt: null, + }; + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId: turn.input.threadId, + runId: turn.input.runId, + parentNodeId: turn.input.rootNodeId, + rootNodeId: turn.input.rootNodeId, + kind: body.type, + status: "waiting", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: ref(native.id), + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }; + const base = { + id: idAllocator.derive.approvalTurnItem({ requestId }), + threadId: turn.input.threadId, + runId: turn.input.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: ref(native.id), + parentItemId: null, + ordinal: ordinalOf(turn, native.id), + status: "waiting" as const, + startedAt: now, + completedAt: null, + updatedAt: now, + }; + const item: OrchestrationV2TurnItem = + body.type === "approval_request" + ? { + ...base, + title: native.type === "permission" ? native.action : null, + type: "approval_request", + requestId, + requestKind: body.requestKind, + prompt: body.prompt, + ...(body.options === undefined ? {} : { options: body.options }), + } + : { + ...base, + title: "User input", + type: "user_input_request", + requestId, + questions: body.questions, + }; + pending.set(requestId, { + request, + item, + node, + state, + turn, + sessionId, + answering: false, + native, + }); + yield* emit({ type: "node.updated", driver, node }); + yield* emit({ + type: "runtime_request.updated", + driver, + threadId: turn.input.threadId, + runtimeRequest: request, + }); + yield* emit({ type: "turn_item.updated", driver, turnItem: item }); + yield* setSessionStatus("waiting", null); + }); + + const onPermissionAsked = Effect.fnUntraced(function* (event: EventOf<"permission.asked">) { + const { data } = event; + const turn = ownerOf(data.sessionID)?.active; + const toolName = + data.source === undefined ? undefined : turn?.tools.get(data.source.id)?.name; + const save = data.save ?? []; + yield* showRequest( + data.sessionID, + { type: "permission", id: data.id, action: data.action, resources: data.resources, save }, + { + type: "approval_request", + requestKind: openCodePermissionRequestKind(data.action, toolName), + prompt: data.resources.length === 0 ? data.action : data.resources.join("\n"), + // "Always" in OpenCode saves a grant for the whole project, so the + // session-wide choice is T3's own rule on this session instead. + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + ...(save.length > 0 + ? [ + { + decision: "acceptForSession" as const, + label: sessionGrantLabel(data.action, save), + }, + ] + : []), + { decision: "accept", label: "Approve" }, + ], + }, + ); + }); + + const onFormCreated = Effect.fnUntraced(function* (event: EventOf<"form.created">) { + const { form } = event.data; + const state = ownerOf(form.sessionID); + if (state?.active === undefined) return; + const mapped = formQuestions(form); + if ("questions" in mapped) { + return yield* showRequest( + form.sessionID, + { type: "form", id: form.id, form }, + { type: "user_input_request", questions: mapped.questions }, + ); } - yield* client.session - .interrupt({ sessionID: Session.ID.make(sessionId) }) - .pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true })); + // Cancelling ends OpenCode's execution as a user stop, so the turn is + // failed here with the reason and that stop's end is skipped. + yield* Effect.logWarning("Declined an OpenCode form T3 Code cannot show.", { + reason: mapped.unsupported, + }); + const cancelled = yield* deliver( + client.session.form.cancel({ sessionID: form.sessionID, formID: form.id }), + ); + if (!cancelled) return yield* abandonRequest(state, "form cancel failed"); + state.unsettled = true; + yield* finishTurn(state, { + status: "failed", + failure: makeProviderFailure({ + message: `OpenCode asked for ${mapped.unsupported}, which T3 Code can't show. The question was declined.`, + class: "provider_error", + }), + }); }); const handleEvent = Effect.fnUntraced(function* (event: OpenCode2StreamEvent) { @@ -719,11 +1130,48 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }, ); } - if (event.type === "permission.asked" && threads.has(event.data.sessionID)) { - return yield* refuseRequest(event); + // A subagent's requests are asked on the thread whose session started it. + if (event.type === "session.created" && event.data.parentID !== undefined) { + const owner = ownerOf(event.data.parentID); + if (owner !== undefined) childOwners.set(event.data.sessionID, owner); + return; + } + if (event.type === "permission.asked" || event.type === "form.created") { + const asking = + event.type === "permission.asked" ? event.data.sessionID : event.data.form.sessionID; + const state = ownerOf(asking); + if (state === undefined) return; + const turn = state.active; + if (turn !== undefined && !turn.awaitingStart) { + if (event.type === "permission.asked") return yield* onPermissionAsked(event); + return yield* onFormCreated(event); + } + // Asked by the run a Stop left behind, which nothing answers. + if (state.unsettled || turn?.awaitingStart === true) { + yield* stopStaleRequest( + asking, + event.type === "permission.asked" + ? { type: "permission", id: event.data.id } + : { type: "form", id: event.data.form.id }, + ); + } + return; } - if (event.type === "form.created" && threads.has(event.data.form.sessionID)) { - return yield* refuseRequest(event); + // Answered in another OpenCode client, or dropped by OpenCode: a reject + // it sends on its own (a Stop, or another reject in the same session) + // cancels the request. T3's own answers are settled where they are sent. + if ( + event.type === "permission.replied" || + event.type === "form.replied" || + event.type === "form.cancelled" + ) { + const nativeId = event.type === "permission.replied" ? event.data.requestID : event.data.id; + const entry = [...pending.values()].find((candidate) => candidate.native.id === nativeId); + if (entry === undefined || entry.answering) return; + const answered = + event.type === "form.replied" || + (event.type === "permission.replied" && event.data.reply !== "reject"); + return yield* settleRequest(entry, answered ? "resolved" : "cancelled"); } if (!("sessionID" in event.data) || typeof event.data.sessionID !== "string") return; const state = threads.get(event.data.sessionID); @@ -749,6 +1197,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); } case "session.tool.input.started": + // Its form is the item the user answers; the tool call would repeat it. + if (event.data.name === "question") return; turn.tools.set(event.data.id, { name: event.data.name, input: {} }); turn.startedAt.set(event.data.id, yield* DateTime.now); return yield* emitTool(state, turn, event.data.id, "running"); @@ -868,25 +1318,110 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); yield* readModels; + // Each agent's own path allows, by the directory they were listed for. + const agentRules = new Map>>(); + const pathsFor = Effect.fnUntraced(function* ( + directory: string, + agents: ReadonlyArray, + ) { + let known = agentRules.get(directory); + if (known === undefined) { + // A fresh server lists no agents for its first moments, like its models. + const listed = yield* client.agent.list({ location: { directory } }).pipe( + Effect.repeat({ + until: (list) => list.data.length > 0, + schedule: Schedule.spaced("250 millis"), + }), + Effect.timeout("5 seconds"), + // The failure can carry the server's URL or response text, so only its + // tag is annotated; the full failure stays in the log's cause. + Effect.tapCause((cause) => + Effect.logWarning( + "Could not list OpenCode agents; their path rules are skipped.", + cause, + ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })), + ), + Effect.option, + ); + if (listed._tag === "None") return []; + known = new Map( + listed.value.data.map((agent) => [agent.id, agentPaths(agent.permissions)]), + ); + agentRules.set(directory, known); + } + const paths = new Map(); + for (const agent of agents) { + for (const entry of known.get(agent) ?? []) { + paths.set(`${entry.action}\u0000${entry.resource}`, entry); + } + } + return [...paths.values()]; + }); + + /** + * The rules a thread's session runs `policy` with. Full access allows + * every path already, so the agent's own path rules are read only for the + * modes that narrow it. + */ + const rulesFor = Effect.fnUntraced(function* ( + thread: Pick, + policy: RulesPolicy, + ) { + const plan = policy.interactionMode === "plan"; + const paths = + policy.runtimeMode === "full-access" && !plan + ? [] + : yield* pathsFor(thread.directory, plan ? [thread.agent, "plan"] : [thread.agent]); + return sessionRules(policy, paths, policy.runtimeMode === "full-access" ? [] : thread.grants); + }); + + /** Writes the session's rules for `policy` when they differ from what it has. */ + const writeRules = Effect.fnUntraced(function* (state: ThreadState, policy: RulesPolicy) { + const rules = yield* rulesFor(state, policy); + if (!sameRules(state.rules, rules)) { + yield* client.session.update({ + sessionID: Session.ID.make(state.sessionId), + permissions: rules, + }); + state.rules = rules; + } + state.policy = policy; + }); + const register = ( providerThread: OrchestrationV2ProviderThread, - sessionId: string, - model: ModelRef | undefined, + native: { + readonly id: string; + readonly model?: ModelRef | undefined; + readonly agent?: string | undefined; + readonly permissions?: ReadonlyArray | undefined; + }, + directory: string, ) => { - const existing = threads.get(sessionId); + const existing = threads.get(native.id); if (existing !== undefined) { existing.providerThread = providerThread; - existing.model = model; - return providerThread; + existing.model = native.model; + existing.directory = directory; + existing.agent = native.agent ?? existing.agent; + existing.rules = native.permissions; + return existing; } - threads.set(sessionId, { + const state: ThreadState = { + sessionId: native.id, providerThread, providerTurns: new Map(), active: undefined, - model, + model: native.model, unsettled: false, - }); - return providerThread; + directory, + agent: native.agent ?? "build", + rules: native.permissions, + policy: input.runtimePolicy, + grants: [], + }; + threads.set(native.id, state); + return state; }; const prompt = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => { @@ -929,12 +1464,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr detail: malformedModel(threadInput.modelSelection.model), }); } + const directory = threadInput.runtimePolicy.cwd ?? serverConfig.cwd; + const policy = threadInput.runtimePolicy; + // A new session runs OpenCode's default agent. + const permissions = yield* rulesFor({ directory, agent: "build", grants: [] }, policy); const created = yield* client.session.create({ - location: Location.PublicRef.make({ - directory: AbsolutePath.make(threadInput.runtimePolicy.cwd ?? serverConfig.cwd), - }), + location: Location.PublicRef.make({ directory: AbsolutePath.make(directory) }), model, - permissions: SESSION_PERMISSIONS, + permissions, }); const createdAt = yield* DateTime.now; const providerThread: OrchestrationV2ProviderThread = { @@ -956,7 +1493,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr status: "idle", updatedAt: createdAt, }; - return register(providerThread, created.id, created.model); + const state = register( + providerThread, + { id: created.id, model: created.model, agent: created.agent, permissions }, + directory, + ); + state.policy = policy; + return providerThread; }).pipe( Effect.mapError((cause) => isProviderAdapterError(cause) @@ -974,32 +1517,29 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // 1.x session ids survive the upgrade; a server without this session // fails the resume, so T3 recreates the thread with a handoff. const native = yield* client.session.get({ sessionID: Session.ID.make(sessionId) }); - // A session made by 1.x or an earlier build may still allow what - // T3 now denies, such as subagents. - if (!sameRules(native.permissions, SESSION_PERMISSIONS)) { - yield* client.session.update({ - sessionID: Session.ID.make(sessionId), - permissions: SESSION_PERMISSIONS, - }); - } - // A thread moved to another worktree takes its session with it. + const providerThread: OrchestrationV2ProviderThread = { + ...threadInput.providerThread, + providerSessionId: input.providerSessionId, + status: "idle", + updatedAt: yield* DateTime.now, + }; const cwd = threadInput.runtimePolicy?.cwd; + const loaded = threads.has(sessionId); + const state = register(providerThread, native, cwd ?? native.location.directory); + // OpenCode keeps no request across its own restart, but a server that + // outlived T3 may still wait on one T3 no longer shows. + if (!loaded) yield* stopLeftoverRequests(state); + // The session gets the rules for this thread's mode: it may have run + // another mode, or been made by 1.x or an earlier build. + yield* writeRules(state, threadInput.runtimePolicy ?? state.policy); + // A thread moved to another worktree takes its session with it. if (cwd != null && native.location.directory !== cwd) { yield* client.session.move({ sessionID: Session.ID.make(sessionId), directory: AbsolutePath.make(cwd), }); } - return register( - { - ...threadInput.providerThread, - providerSessionId: input.providerSessionId, - status: "idle", - updatedAt: yield* DateTime.now, - }, - sessionId, - native.model, - ); + return providerThread; }).pipe( Effect.mapError((cause) => isProviderAdapterError(cause) @@ -1112,22 +1652,20 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); } // A turn T3 will not run still starts and fails, so the refusal is what - // the user reads. Sessions allow every tool, so any other mode would - // silently run as Full access. + // the user reads. const model = modelRef(turnInput.modelSelection); - if (turnInput.runtimePolicy.runtimeMode !== "full-access" || model === undefined) { + if (model === undefined) { yield* begin; return yield* finishTurn(state, { status: "failed", failure: makeProviderFailure({ - message: - turnInput.runtimePolicy.runtimeMode !== "full-access" - ? OPENCODE_2_FULL_ACCESS_ONLY - : malformedModel(turnInput.modelSelection.model), + message: malformedModel(turnInput.modelSelection.model), class: "validation_error", }), }); } + // The thread's mode may have changed since the session was loaded. + yield* writeRules(state, turnInput.runtimePolicy); // A selection changed since the last turn applies now; OpenCode keeps // the session's model otherwise. if (!sameModel(model, state.model)) { @@ -1244,21 +1782,99 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr unloadThread: ({ providerThread }) => Effect.sync(() => { const nativeId = providerThread.nativeThreadRef?.nativeId; - if ( - nativeId !== undefined && - nativeId !== null && - threads.get(nativeId)?.active === undefined - ) { - threads.delete(nativeId); + const state = nativeId == null ? undefined : threads.get(nativeId); + if (nativeId == null || state === undefined || state.active !== undefined) return; + threads.delete(nativeId); + for (const [child, owner] of childOwners) { + if (owner === state) childOwners.delete(child); } }), respondToRuntimeRequest: (requestInput) => - Effect.fail( - new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ - driver, - requestId: requestInput.requestId, - cause: notYet("answering runtime requests"), - }), + Effect.gen(function* () { + const entry = pending.get(requestInput.requestId); + if (entry === undefined || entry.answering) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `No pending OpenCode request ${requestInput.requestId}`, + }); + } + const { decision, answers } = requestInput; + const { native } = entry; + if (native.type === "permission" && decision === undefined) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `OpenCode approval request ${requestInput.requestId} requires a decision`, + }); + } + entry.answering = true; + // OpenCode's own "always" saves a grant for the whole project, so a + // session-wide answer is a rule on this session instead. The grant + // is best effort: this request is answered either way. + if ( + native.type === "permission" && + (decision === "acceptForSession" || decision === "acceptAlways") + ) { + const { state } = entry; + for (const resource of native.save) { + if ( + !state.grants.some( + (grant) => grant.action === native.action && grant.resource === resource, + ) + ) { + state.grants.push({ action: native.action, resource, effect: "allow" }); + } + } + yield* writeRules(state, state.policy).pipe(Effect.ignore({ log: true })); + } + const sessionID = Session.ID.make(entry.sessionId); + // Best effort: the decline stands without the note. + if (native.type === "permission" && decision === "decline") { + yield* client.session + .synthetic({ + sessionID, + text: declinedNote(native.action, native.resources), + delivery: "steer", + resume: false, + }) + .pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT), Effect.ignore({ log: true })); + } + const delivered = yield* native.type === "permission" + ? deliver( + client.permission + .reply({ + sessionID, + requestID: Permission.ID.make(native.id), + decision: decision !== undefined && approves(decision) ? "once" : "reject", + // Without a message OpenCode ends the whole run, which is Cancel. + ...(decision === "decline" ? { message: DECLINED } : {}), + }) + .pipe(Effect.catchTags(permissionGone)), + ) + : deliver( + (answers === undefined || decision === "decline" || decision === "cancel" + ? client.session.form.cancel({ + sessionID: entry.sessionId, + formID: Form.ID.make(native.id), + }) + : client.session.form.reply({ + sessionID: entry.sessionId, + formID: Form.ID.make(native.id), + answer: formAnswer(native.form, answers), + }) + ).pipe(Effect.catchTags(formGone)), + ); + yield* forgetRequest(entry); + if (!delivered) yield* abandonRequest(entry.state, "answer not delivered"); + }).pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ + driver, + requestId: requestInput.requestId, + cause, + }), + ), ), readThreadSnapshot: ({ providerThread }) => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 98ee10c1ce41..66390f857e7c 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -10,6 +10,7 @@ import { type ModelSelection, type OrchestrationV2Command, ProjectId, + type ProviderInteractionMode, ProviderInstanceId, type ProviderReplayEntry, type RuntimeMode, @@ -17,7 +18,6 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { OPENCODE_2_FULL_ACCESS_ONLY } from "./Adapters/OpenCode2AdapterV2.ts"; import { OPENCODE2_HTTP_PROTOCOL, OpenCode2OrchestratorReplayHarness, @@ -52,6 +52,75 @@ const T3_RULES = [ { action: "*", resource: "*", effect: "allow" }, { action: "subagent", resource: "*", effect: "deny" }, ]; +/** Paths the build and plan agents allow for themselves, as 2.0.18 lists them. */ +const BUILD_PATHS = [ + { + action: "external_directory", + resource: "/home/.local/share/opencode/tool-output/*", + effect: "allow", + }, +]; +const PLAN_PATHS = [ + ...BUILD_PATHS, + { action: "edit", resource: "/home/.opencode/plan/*", effect: "allow" }, + { action: "external_directory", resource: "/home/.opencode/plan/*", effect: "allow" }, +]; +const agentInfo = (id: string, description: string, permissions: ReadonlyArray) => ({ + id, + name: id === "plan" ? "Plan" : "Build", + request: { settings: {}, headers: {}, body: {} }, + description, + mode: "primary", + hidden: false, + permissions, +}); +/** `/api/agent` trimmed to the two agents a T3 session runs. */ +const agentList = (directory: string) => ({ + location: { directory }, + data: [ + agentInfo("build", "The default agent.", [ + { action: "*", resource: "*", effect: "allow" }, + { action: "external_directory", resource: "*", effect: "ask" }, + ...BUILD_PATHS, + ]), + agentInfo("plan", "Read-only agent for planning.", [ + { action: "*", resource: "*", effect: "allow" }, + { action: "external_directory", resource: "*", effect: "ask" }, + ...BUILD_PATHS, + { action: "edit", resource: "*", effect: "deny" }, + ...PLAN_PATHS.slice(BUILD_PATHS.length), + ]), + ], +}); +/** A session this runtime loads again (after a detach) waits on nothing. */ +const noOpenRequests: ReadonlyArray = [ + out("permission.list", { sessionID: SESSION }), + reply("permission.list", { data: [] }), + out("session.form.list", { sessionID: SESSION }), + reply("session.form.list", { data: [] }), +]; +const SUPERVISED_RULES = [ + { action: "shell", resource: "*", effect: "ask" }, + { action: "edit", resource: "*", effect: "ask" }, + { action: "external_directory", resource: "*", effect: "ask" }, + ...BUILD_PATHS, + { action: "subagent", resource: "*", effect: "deny" }, +]; +const AUTO_EDIT_RULES = [ + { action: "shell", resource: "*", effect: "ask" }, + { action: "edit", resource: "*", effect: "allow" }, + { action: "external_directory", resource: "*", effect: "ask" }, + ...BUILD_PATHS, + { action: "subagent", resource: "*", effect: "deny" }, +]; +/** Plan mode on Full access: edits are denied except the plan agent's own plan files. */ +const PLAN_RULES = [ + { action: "*", resource: "*", effect: "allow" }, + { action: "edit", resource: "*", effect: "deny" }, + ...PLAN_PATHS, + { action: "subagent", resource: "*", effect: "deny" }, +]; + const sessionInfo = (directory: string, permissions: ReadonlyArray = T3_RULES) => ({ data: { id: SESSION, @@ -103,7 +172,10 @@ const catalogModel = (id: string, name: string) => ({ enabled: true, limit: { context: 200000, input: 160000, output: 32000 }, }); -const createdSession = (directory: string): ReadonlyArray => [ +const createdSession = ( + directory: string, + permissions: ReadonlyArray = T3_RULES, +): ReadonlyArray => [ out("event.subscribe"), out("model.list", ""), reply("model.list", { @@ -113,14 +185,19 @@ const createdSession = (directory: string): ReadonlyArray = catalogModel("mimo-v2.6-flash-free", "MiMo V2.6 Flash Free"), ], }), - out("session.create", ""), - reply("session.create", sessionInfo(directory)), + // Only a mode that narrows Full access reads the agents' own path rules. + ...(permissions === T3_RULES + ? [] + : [out("agent.list", ""), reply("agent.list", agentList(directory))]), + out("session.create", { location: { directory }, model: "", permissions }), + reply("session.create", sessionInfo(directory, permissions)), ]; const threadCommands = (input: { readonly name: string; readonly worktreePath: string; readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; }) => { const threadId = ThreadId.make(`thread:${input.name}`); const command = (key: string) => CommandId.make(`command:${input.name}:${key}`); @@ -136,7 +213,7 @@ const threadCommands = (input: { title: input.name, modelSelection: bigPickle, runtimeMode: input.runtimeMode ?? "full-access", - interactionMode: "default", + interactionMode: input.interactionMode ?? "default", branch: null, worktreePath: input.worktreePath, } satisfies OrchestrationV2Command, @@ -256,6 +333,8 @@ describe("OpenCode 2 through the orchestrator", () => { ...answeredPrompt("FIRST"), out("session.get", { sessionID: SESSION }), reply("session.get", sessionInfo(before)), + // The worktree change detached the thread, so its session is loaded afresh. + ...noOpenRequests, out("session.move", { sessionID: SESSION, directory: after }), reply("session.move", null), ...answeredPrompt("SECOND"), @@ -299,6 +378,7 @@ describe("OpenCode 2 through the orchestrator", () => { "session.get", sessionInfo(before, [{ action: "*", resource: "*", effect: "allow" }]), ), + ...noOpenRequests, out("session.update", { sessionID: SESSION, permissions: T3_RULES }), reply("session.update", null), out("session.move", { sessionID: SESSION, directory: after }), @@ -324,26 +404,112 @@ describe("OpenCode 2 through the orchestrator", () => { }).pipe(Effect.scoped), ); - it.effect("fails a turn outside Full access with the refusal, never prompting", () => + it.effect( + "creates a Supervised thread's session with rules that ask before shell and edits", + () => + Effect.gen(function* () { + const name = "opencode2-supervised-rules"; + const cwd = yield* checkpointWorkspace(name); + const thread = threadCommands({ + name, + worktreePath: cwd, + runtimeMode: "approval-required", + }); + const projection = yield* runScenario({ + name, + threadId: thread.threadId, + entries: [...createdSession(cwd, SUPERVISED_RULES), ...answeredPrompt("FIRST")], + commands: [thread.create, thread.message("first")], + }); + assert.deepEqual( + projection.runs.map((run) => run.status), + ["completed"], + ); + }).pipe(Effect.scoped), + ); + + it.effect("rewrites the session's rules when the thread's mode changes between turns", () => Effect.gen(function* () { - const name = "opencode2-supervised-refusal"; + const name = "opencode2-mode-change"; const cwd = yield* checkpointWorkspace(name); - const thread = threadCommands({ name, worktreePath: cwd, runtimeMode: "approval-required" }); + const thread = threadCommands({ name, worktreePath: cwd }); + const setMode = (key: string, runtimeMode: RuntimeMode) => + ({ + type: "thread.runtime-mode.set", + commandId: thread.command(key), + threadId: thread.threadId, + runtimeMode, + }) satisfies OrchestrationV2Command; const projection = yield* runScenario({ name, threadId: thread.threadId, - // The session is created, then the turn is refused: no prompt is expected. - entries: createdSession(cwd), - commands: [thread.create, thread.message("refused")], + entries: [ + ...createdSession(cwd), + ...answeredPrompt("FIRST"), + // A mode change detaches nothing: the same session is resumed with the new rules. + out("session.get", { sessionID: SESSION }), + reply("session.get", sessionInfo(cwd)), + out("agent.list", ""), + reply("agent.list", agentList(cwd)), + out("session.update", { sessionID: SESSION, permissions: AUTO_EDIT_RULES }), + reply("session.update", null), + ...answeredPrompt("SECOND"), + // Back to Full access: the narrowing rules go. + out("session.get", { sessionID: SESSION }), + reply("session.get", sessionInfo(cwd, AUTO_EDIT_RULES)), + out("session.update", { sessionID: SESSION, permissions: T3_RULES }), + reply("session.update", null), + ...answeredPrompt("THIRD"), + ], + commands: [ + thread.create, + thread.message("first"), + setMode("auto-edit", "auto-accept-edits"), + thread.message("second"), + setMode("full", "full-access"), + thread.message("third"), + ], }); assert.deepEqual( projection.runs.map((run) => run.status), - ["failed"], + ["completed", "completed", "completed"], ); - const failure = projection.turnItems.find((item) => item.type === "error"); - assert.equal( - failure?.type === "error" ? failure.failure.message : undefined, - OPENCODE_2_FULL_ACCESS_ONLY, + assert.lengthOf(projection.providerThreads, 1); + }).pipe(Effect.scoped), + ); + + it.effect("denies edits outside the plan directory in plan mode and lifts it after", () => + Effect.gen(function* () { + const name = "opencode2-plan-rules"; + const cwd = yield* checkpointWorkspace(name); + const thread = threadCommands({ name, worktreePath: cwd, interactionMode: "plan" }); + const projection = yield* runScenario({ + name, + threadId: thread.threadId, + entries: [ + ...createdSession(cwd, PLAN_RULES), + ...answeredPrompt("PLANNED"), + out("session.get", { sessionID: SESSION }), + reply("session.get", sessionInfo(cwd, PLAN_RULES)), + out("session.update", { sessionID: SESSION, permissions: T3_RULES }), + reply("session.update", null), + ...answeredPrompt("BUILT"), + ], + commands: [ + thread.create, + thread.message("plan"), + { + type: "thread.interaction-mode.set", + commandId: thread.command("default"), + threadId: thread.threadId, + interactionMode: "default", + }, + thread.message("build"), + ], + }); + assert.deepEqual( + projection.runs.map((run) => run.status), + ["completed", "completed"], ); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 9e695048e73f..87a4dff5ac0b 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -1,13 +1,16 @@ /** * Runs OpenCode 2 through the whole orchestrator with the real driver: the * driver probes the binary, spawns `opencode serve`, and routes to the 2.x - * adapter. One turn reads a file and runs a shell command; a second is - * stopped while its shell command runs. + * adapter. One thread reads a file, runs a shell command, and stops another; + * a Supervised thread approves one command, declines another, and answers a + * question; a plan-mode thread may write only its plan. * - * OPENCODE2_BIN=/path/to/opencode vp test run src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts + * OPENCODE2_BIN=/path/to/opencode OPENCODE2_LIVE_ROOT=/scratch/dir \ + * vp test run src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts * * The server runs with isolated HOME and XDG directories on the free - * `opencode/big-pickle` model; `OPENCODE2_MODEL` picks another. + * `opencode/big-pickle` model; `OPENCODE2_MODEL` picks another (its provider's + * key comes from the test's environment, which the spawned server inherits). */ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -324,21 +327,156 @@ describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchest const models = yield* assistantModels(sessionId!); assert.equal(models.at(-1), SWITCHED_MODEL); assert.notEqual(models[0], SWITCHED_MODEL); + }).pipe(Effect.provide(Layer.merge(liveLayer, NodeServices.layer)), Effect.scoped), + 360_000, + ); - // Supervised threads are refused, not run with every tool allowed. + it.live( + "asks before each shell command in Supervised, runs the approved one, skips the declined one, and answers a question", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("thread:opencode2-live-supervised"); yield* orchestrator.dispatch({ - type: "thread.runtime-mode.set", - commandId: CommandId.make("command:opencode2-live:runtime-mode"), + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:opencode2-live:supervised-create"), threadId, + projectId: ProjectId.make("project:opencode2-live"), + title: "OpenCode 2 live supervised", + modelSelection: MODEL, runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: `${ROOT}/work`, + }); + const pendingAsk = (projection: OrchestrationV2ThreadProjection) => + projection.runtimeRequests.find((request) => request.status === "pending"); + const answer = ( + key: string, + request: OrchestrationV2ThreadProjection["runtimeRequests"][number], + response: { decision: "accept" | "decline" } | { answers: Record }, + ) => + orchestrator.dispatch({ + type: "runtime-request.respond", + commandId: CommandId.make(`command:opencode2-live:${key}`), + threadId, + requestId: request.id, + ...response, + }); + + yield* send( + threadId, + "approve", + "Run the shell command `touch approved.txt` with the shell tool, then reply DONE.", + ); + const asked = yield* waitFor( + threadId, + (projection) => pendingAsk(projection) !== undefined, + ); + assert.equal(pendingAsk(asked)?.kind, "command"); + yield* answer("approve-answer", pendingAsk(asked)!, { decision: "accept" }); + const approved = yield* waitFor(threadId, settled); + assert.equal(approved.runs.at(-1)?.status, "completed"); + assert.isTrue(yield* fs.exists(path.join(ROOT, "work", "approved.txt"))); + + yield* send( + threadId, + "decline", + "Run the shell command `touch declined.txt` with the shell tool, then reply DONE.", + ); + const second = yield* waitFor( + threadId, + (projection) => projection.runs.length === 2 && pendingAsk(projection) !== undefined, + ); + yield* answer("decline-answer", pendingAsk(second)!, { decision: "decline" }); + // The model may try again; every retry is declined too. + let declined = yield* waitFor( + threadId, + (projection) => + (projection.runs.length === 2 && settled(projection)) || + pendingAsk(projection) !== undefined, + ); + for (let retry = 0; pendingAsk(declined) !== undefined && retry < 3; retry += 1) { + yield* answer(`decline-retry-${retry}`, pendingAsk(declined)!, { decision: "decline" }); + declined = yield* waitFor( + threadId, + (projection) => + (projection.runs.length === 2 && settled(projection)) || + pendingAsk(projection) !== undefined, + ); + } + assert.equal(declined.runs.at(-1)?.status, "completed"); + assert.isFalse(yield* fs.exists(path.join(ROOT, "work", "declined.txt"))); + + yield* send( + threadId, + "question", + "Before doing anything, use the question tool to ask me which color I prefer, offering the options red and blue. After I answer, reply with only the chosen color.", + ); + const questioned = yield* waitFor( + threadId, + (projection) => projection.runs.length === 3 && pendingAsk(projection) !== undefined, + ); + const question = pendingAsk(questioned)!; + assert.equal(question.kind, "user_input"); + const form = questioned.turnItems.find( + (item) => item.type === "user_input_request" && item.requestId === question.id, + ); + const firstQuestion = form?.type === "user_input_request" ? form.questions[0] : undefined; + assert.isDefined(firstQuestion); + yield* answer("question-answer", question, { answers: { [firstQuestion!.id]: "Blue" } }); + const answered = yield* waitFor( + threadId, + (projection) => projection.runs.length === 3 && settled(projection), + ); + assert.equal(answered.runs.at(-1)?.status, "completed"); + const reply = answered.messages.findLast((message) => message.role === "assistant"); + assert.match(reply?.text ?? "", /blue/i); + }).pipe(Effect.provide(Layer.merge(liveLayer, NodeServices.layer)), Effect.scoped), + 600_000, + ); + + it.live( + "lets plan mode write only the plan agent's plan directory under Full access", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("thread:opencode2-live-plan"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:opencode2-live:plan-create"), + threadId, + projectId: ProjectId.make("project:opencode2-live"), + title: "OpenCode 2 live plan", + modelSelection: MODEL, + runtimeMode: "full-access", + interactionMode: "plan", + branch: null, + worktreePath: `${ROOT}/work`, }); - yield* send(threadId, "supervised", "Create a file named supervised.txt containing NO."); - const refused = yield* waitFor( + // The plan agent's directory: `$HOME/.opencode/plan` under the isolated HOME. + const planDir = path.join(ROOT, ".opencode", "plan"); + // No agent switch yet (a later layer adds it), so the build agent runs + // under the session's plan rules: the edit deny holds on its own. + yield* send( threadId, - (projection) => projection.runs.length === 4 && settled(projection), + "plan", + `This is a permission test. Do exactly these two tool calls and nothing else: 1) use the write tool to create ${planDir}/probe-plan.md containing PLAN_OK; 2) use the write tool to create plan_write_probe.txt in the current directory containing NO. Then reply with which calls succeeded.`, ); - assert.equal(refused.runs.at(-1)?.status, "failed"); - assert.isFalse(yield* fs.exists(path.join(ROOT, "work", "supervised.txt"))); + const planned = yield* waitFor(threadId, settled); + assert.equal(planned.runs.at(-1)?.status, "completed"); + assert.isFalse(yield* fs.exists(path.join(ROOT, "work", "plan_write_probe.txt"))); + assert.isTrue(yield* fs.exists(path.join(planDir, "probe-plan.md"))); }).pipe(Effect.provide(Layer.merge(liveLayer, NodeServices.layer)), Effect.scoped), 360_000, ); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index 867d9f55cb94..eda11b94d651 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -57,6 +57,10 @@ import { assertOpenCodeRunningChildApprovalOutput } from "./opencode_running_chi import { openCodeSubagentInput } from "./opencode_subagent/input.ts"; import { openCode2InterruptInput } from "./opencode2_interrupt/input.ts"; import { assertOpenCode2InterruptOutput } from "./opencode2_interrupt/output.ts"; +import { openCode2PermissionInput } from "./opencode2_permission/input.ts"; +import { assertOpenCode2PermissionOutput } from "./opencode2_permission/output.ts"; +import { openCode2QuestionInput } from "./opencode2_question/input.ts"; +import { assertOpenCode2QuestionOutput } from "./opencode2_question/output.ts"; import { openCode2SimpleInput } from "./opencode2_simple/input.ts"; import { assertOpenCode2SimpleOutput } from "./opencode2_simple/output.ts"; import { openCode2ToolCallInput } from "./opencode2_tool_call/input.ts"; @@ -816,6 +820,39 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray"}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} +{"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c42f4001mVTHzUxqQZcbbY", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "agent.list", "frame": {"type": "agent.list", "input": {"location[directory]": ""}}} +{"type": "emit_inbound", "label": "agent.list.response", "frame": {"type": "sdk.response", "operation": "agent.list", "data": {"location": {"directory": ""}, "data": [{"id": "build", "name": "Build", "request": {"settings": {}, "headers": {}, "body": {}}, "description": "The default agent. Executes tools based on configured permissions.", "mode": "primary", "hidden": false, "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "external_directory", "resource": "*", "effect": "ask"}, {"action": "read", "resource": "*.env", "effect": "ask"}, {"action": "read", "resource": "*.env.*", "effect": "ask"}, {"action": "read", "resource": "*.env.example", "effect": "allow"}, {"action": "external_directory", "resource": "/data/opencode/shell/*/*", "effect": "allow"}, {"action": "external_directory", "resource": "/data/opencode/tool-output/*", "effect": "allow"}, {"action": "external_directory", "resource": "/tmp/opencode/*", "effect": "allow"}, {"action": "external_directory", "resource": "/config/opencode/*", "effect": "allow"}, {"action": "question", "resource": "*", "effect": "allow"}]}, {"id": "plan", "name": "Plan", "request": {"settings": {}, "headers": {}, "body": {}}, "description": "Read-only agent for exploring the codebase and planning work before implementation.", "mode": "primary", "hidden": false, "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "external_directory", "resource": "*", "effect": "ask"}, {"action": "read", "resource": "*.env", "effect": "ask"}, {"action": "read", "resource": "*.env.*", "effect": "ask"}, {"action": "read", "resource": "*.env.example", "effect": "allow"}, {"action": "external_directory", "resource": "/data/opencode/shell/*/*", "effect": "allow"}, {"action": "external_directory", "resource": "/data/opencode/tool-output/*", "effect": "allow"}, {"action": "external_directory", "resource": "/tmp/opencode/*", "effect": "allow"}, {"action": "external_directory", "resource": "/config/opencode/*", "effect": "allow"}, {"action": "question", "resource": "*", "effect": "allow"}, {"action": "edit", "resource": "*", "effect": "deny"}, {"action": "edit", "resource": "/.opencode/plan/*", "effect": "allow"}, {"action": "external_directory", "resource": "/.opencode/plan/*", "effect": "allow"}]}]}}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f1483bd0affez3sG5IbKgOai7e", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657184522, "updated": 1790657184522}, "title": "spike permission", "permissions": [{"action": "shell", "resource": "*", "effect": "ask"}, {"action": "edit", "resource": "*", "effect": "ask"}, {"action": "external_directory", "resource": "*", "effect": "ask"}, {"action": "external_directory", "resource": "/data/opencode/shell/*/*", "effect": "allow"}, {"action": "external_directory", "resource": "/data/opencode/tool-output/*", "effect": "allow"}, {"action": "external_directory", "resource": "/tmp/opencode/*", "effect": "allow"}, {"action": "external_directory", "resource": "/config/opencode/*", "effect": "allow"}, {"action": "subagent", "resource": "*", "effect": "deny"}], "location": {"directory": ""}}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c43090019BtXQ5r0ZjDWSV", "created": 1790657184522, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "slug": "misty-orchid", "version": "2.0.18", "projectID": "global", "location": {"directory": ""}, "subpath": "", "title": "spike permission", "permissions": [{"action": "shell", "resource": "*", "effect": "ask"}], "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7c4316001q0CD2uF5bwp7Mo", "sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "time": {"created": 1790657184536}, "type": "user", "payload": {"text": "Run the shell command `echo FIRST` with the bash tool. After it completes, run `echo SECOND` with the bash tool. Then reply with what happened."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4318001qcZWVfG736x3eX", "created": 1790657184536, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7c4316001q0CD2uF5bwp7Mo", "sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "item": {"type": "user", "payload": {"text": "Run the shell command `echo FIRST` with the bash tool. After it completes, run `echo SECOND` with the bash tool. Then reply with what happened."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c431c001rDE642GRAw9G0R", "created": 1790657184540, "type": "session.execution.started", "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c43260012JsxveiVqwFiZ0", "created": 1790657184550, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "delta": {"core/environment": "9761e5a43c1af65a3cf08d0150db09a3c0f8807bfa6af14a33e9caa3201186bd", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4329001MM1eloyZe5Xkhe", "created": 1790657184553, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "inboxID": "msg_0eb7c4316001q0CD2uF5bwp7Mo"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4c42001iAF1FVoJeyF43M", "created": 1790657186882, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7c4330001dYFQKuTpfD780v", "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "started": 1790657184584}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4c45001H0UM1K3Fr9tcF6", "created": 1790657186885, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4330001dYFQKuTpfD780v", "ordinal": 0}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4cab001AaSQt9j4FkSkLC", "created": 1790657186988, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4330001dYFQKuTpfD780v", "ordinal": 0, "delta": "I'll run those"}}}} +{"type": "emit_inbound", "label": "session.text.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4d71001guWsKGA2fMxvxu", "created": 1790657187185, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4330001dYFQKuTpfD780v", "ordinal": 0, "delta": " two commands in sequence."}}}} +{"type": "emit_inbound", "label": "session.tool.input.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4d72001sdXs2oE9d0rVqW", "created": 1790657187186, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4330001dYFQKuTpfD780v", "id": "call_function_dhm47qqj6kq7_1", "name": "shell"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4d75001fWG4pnJjulYnDB", "created": 1790657187189, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4330001dYFQKuTpfD780v", "id": "call_function_dhm47qqj6kq7_1", "text": "{\"command\": \"echo FIRST\"}"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4d77001suVkAfaN6IDu6S", "created": 1790657187191, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4330001dYFQKuTpfD780v", "id": "call_function_dhm47qqj6kq7_1", "input": {"command": "echo FIRST"}, "executed": false}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4d78001MiaPqZC4g5HoQm", "created": 1790657187192, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4330001dYFQKuTpfD780v", "ordinal": 0, "text": "I'll run those two commands in sequence."}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 10, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4d7a001ZL9XBNqNaZhDfR", "created": 1790657187194, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4330001dYFQKuTpfD780v"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "permission.asked", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4d7f001RsDYiTj0zuDt0Z", "created": 1790657187199, "type": "permission.asked", "location": {"directory": ""}, "data": {"id": "per_0eb7c4d7e001Pyt8o50Vi4KrOO", "sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "action": "shell", "resources": ["echo FIRST"], "save": ["echo *"], "source": {"type": "tool", "messageID": "msg_0eb7c4330001dYFQKuTpfD780v", "id": "call_function_dhm47qqj6kq7_1"}}}}} +{"type": "expect_outbound", "label": "permission.reply", "frame": {"type": "permission.reply", "input": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "requestID": "per_0eb7c4d7e001Pyt8o50Vi4KrOO", "decision": "once"}}} +{"type": "emit_inbound", "label": "permission.reply.response", "frame": {"type": "sdk.response", "operation": "permission.reply", "data": null}} +{"type": "emit_inbound", "label": "permission.replied", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4d93001iKDhrQN0guxGPu", "created": 1790657187219, "type": "permission.replied", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "requestID": "per_0eb7c4d7e001Pyt8o50Vi4KrOO", "reply": "once"}}}} +{"type": "emit_inbound", "label": "shell.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4d9a0016aWfcm1CZHk1vp", "created": 1790657187226, "type": "shell.created", "location": {"directory": ""}, "data": {"info": {"id": "sh_0eb7c4d95001BLia1nHaAsK4NU", "status": "running", "command": "echo FIRST", "cwd": "", "shell": "/bin/bash", "file": "/xdg-data/opencode/shell/global/sh_0eb7c4d95001BLia1nHaAsK4NU.out", "metadata": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e"}, "time": {"started": 1790657187221}}}}}} +{"type": "emit_inbound", "label": "session.tool.progress", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4d9b001wC0ythf4TSo2Lt", "created": 1790657187227, "type": "session.tool.progress", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4330001dYFQKuTpfD780v", "id": "call_function_dhm47qqj6kq7_1", "metadata": {"shellID": "sh_0eb7c4d95001BLia1nHaAsK4NU"}}}}} +{"type": "emit_inbound", "label": "shell.exited", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4d9f0018OgWiBi0Qb0uo1", "created": 1790657187231, "type": "shell.exited", "location": {"directory": ""}, "data": {"id": "sh_0eb7c4d95001BLia1nHaAsK4NU", "exit": 0, "status": "exited"}}}} +{"type": "emit_inbound", "label": "session.tool.success", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4da1001oZlWQ23KRooy4S", "created": 1790657187233, "type": "session.tool.success", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4330001dYFQKuTpfD780v", "id": "call_function_dhm47qqj6kq7_1", "content": [{"type": "text", "text": "FIRST\n"}], "metadata": {"status": "completed", "truncated": false, "exit": 0}, "executed": false}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 12, "version": 2}}}} +{"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4db1001RqfIRxHcru1Ewo", "created": 1790657187249, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4330001dYFQKuTpfD780v", "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 8774, "output": 35, "reasoning": 0, "cache": {"read": 486, "write": 0}}, "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": []}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 13, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4db6001wkGIQwTRImocaQ", "created": 1790657187254, "type": "session.usage.updated", "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "cost": 0, "tokens": {"input": 8774, "output": 35, "reasoning": 0, "cache": {"read": 486, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.step.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5245001sAkNDi3kdPN2lA", "created": 1790657188421, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7c4dc3001s5IJ6s7bBL8Lfd", "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "started": 1790657187296}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 14, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5249001rvibjeU92NgejW", "created": 1790657188425, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4dc3001s5IJ6s7bBL8Lfd", "ordinal": 0}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 15, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c52b0001pnhF1ZnkCu0t49", "created": 1790657188528, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4dc3001s5IJ6s7bBL8Lfd", "ordinal": 0, "delta": "First"}}}} +{"type": "emit_inbound", "label": "session.text.delta.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c535b001NEDMubFcxxa1ag", "created": 1790657188699, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4dc3001s5IJ6s7bBL8Lfd", "ordinal": 0, "delta": " command done. Now the second:"}}}} +{"type": "emit_inbound", "label": "session.tool.input.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c535d001UXcTBSv0O5OcyB", "created": 1790657188701, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4dc3001s5IJ6s7bBL8Lfd", "id": "call_function_493baq68fweu_1", "name": "shell"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 16, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5366001R6Wf4gIDHkJejt", "created": 1790657188710, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4dc3001s5IJ6s7bBL8Lfd", "id": "call_function_493baq68fweu_1", "text": "{\"command\": \"echo SECOND\"}"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 17, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5369001g5qXjhK2PDKiUs", "created": 1790657188714, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4dc3001s5IJ6s7bBL8Lfd", "id": "call_function_493baq68fweu_1", "input": {"command": "echo SECOND"}, "executed": false}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 18, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c536d001MGB7VIZF1exBz4", "created": 1790657188717, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4dc3001s5IJ6s7bBL8Lfd", "ordinal": 0, "text": "First command done. Now the second:"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 19, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5370001tGrfJOQyGgxLt6", "created": 1790657188720, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4dc3001s5IJ6s7bBL8Lfd"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 20, "version": 1}}}} +{"type": "emit_inbound", "label": "permission.asked.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5376002ZPeaLXY0m2NSr5", "created": 1790657188726, "type": "permission.asked", "location": {"directory": ""}, "data": {"id": "per_0eb7c5376001gkpJzQEeWtaoRE", "sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "action": "shell", "resources": ["echo SECOND"], "save": ["echo *"], "source": {"type": "tool", "messageID": "msg_0eb7c4dc3001s5IJ6s7bBL8Lfd", "id": "call_function_493baq68fweu_1"}}}}} +{"type": "expect_outbound", "label": "session.synthetic", "frame": {"type": "session.synthetic", "input": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "text": "", "delivery": "steer", "resume": false}}} +{"type": "emit_inbound", "label": "session.synthetic.response", "frame": {"type": "sdk.response", "operation": "session.synthetic", "data": {"data": {"id": "msg_0f1fdc3b6001Deu0vB18PfvoxD", "sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "time": {"created": 1790766334903}, "type": "synthetic", "payload": {"text": "note"}, "delivery": "steer"}}}} +{"type": "expect_outbound", "label": "permission.reply.2", "frame": {"type": "permission.reply", "input": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "requestID": "per_0eb7c5376001gkpJzQEeWtaoRE", "decision": "reject", "message": ""}}} +{"type": "emit_inbound", "label": "permission.reply.response.2", "frame": {"type": "sdk.response", "operation": "permission.reply", "data": null}} +{"type": "emit_inbound", "label": "permission.replied.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c537f001nBNasCSC6REGF7", "created": 1790657188735, "type": "permission.replied", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "requestID": "per_0eb7c5376001gkpJzQEeWtaoRE", "reply": "reject"}}}} +{"type": "emit_inbound", "label": "session.tool.failed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5381001zUGVy3roZN0fUh", "created": 1790657188737, "type": "session.tool.failed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4dc3001s5IJ6s7bBL8Lfd", "id": "call_function_493baq68fweu_1", "error": {"type": "tool.execution", "message": "Unable to execute command: echo SECOND"}, "executed": false}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 21, "version": 2}}}} +{"type": "emit_inbound", "label": "session.step.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5393001EXI6S3VJTx5tBS", "created": 1790657188755, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c4dc3001s5IJ6s7bBL8Lfd", "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 20, "output": 35, "reasoning": 0, "cache": {"read": 9293, "write": 0}}, "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": []}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 22, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c53960012vWhPfkHvPzPGO", "created": 1790657188758, "type": "session.usage.updated", "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "cost": 0, "tokens": {"input": 8794, "output": 70, "reasoning": 0, "cache": {"read": 9779, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.step.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5769001qXcp0Ksi2POq55", "created": 1790657189737, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "started": 1790657188798}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 23, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c576d001si65G66s68WyV4", "created": 1790657189741, "type": "session.reasoning.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "ordinal": 0, "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 24, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c57d5001OpsicjPaNeXpKV", "created": 1790657189845, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "ordinal": 0, "delta": "The second command failed with an"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c59ab001guhIDJe4HndQBH", "created": 1790657190315, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "ordinal": 0, "delta": " execution error. I should report faithfully what happened. Let me consider retrying once to see if it's transient."}}}} +{"type": "emit_inbound", "label": "session.text.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c59ac001lBjFddnYT2IzKT", "created": 1790657190316, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "ordinal": 0}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 25, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5a130014TiUKOIqBeuU1k", "created": 1790657190419, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "ordinal": 0, "delta": "The second call"}}}} +{"type": "emit_inbound", "label": "session.text.delta.6", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5afc001J0xZlpM12IY3Tq", "created": 1790657190652, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "ordinal": 0, "delta": " returned an execution error rather than output. Let me retry once to check whether it's transient:"}}}} +{"type": "emit_inbound", "label": "session.tool.input.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5afd001l4HoE9mEBef1G0", "created": 1790657190653, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "id": "call_function_9notyrs0ad2u_1", "name": "shell"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 26, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5b01001tfilrAw121ZBRE", "created": 1790657190658, "type": "session.reasoning.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "ordinal": 0, "text": "The second command failed with an execution error. I should report faithfully what happened. Let me consider retrying once to see if it's transient.", "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 27, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5b0c001OAf2ZLfYJFypvD", "created": 1790657190668, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "id": "call_function_9notyrs0ad2u_1", "text": "{\"command\": \"echo SECOND\"}"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 28, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5b12001KLuHYWCC1OiMm7", "created": 1790657190674, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "id": "call_function_9notyrs0ad2u_1", "input": {"command": "echo SECOND"}, "executed": false}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 29, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.ended.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5b15001Y70R0iydAwnRZk", "created": 1790657190677, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "ordinal": 0, "text": "The second call returned an execution error rather than output. Let me retry once to check whether it's transient:"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 30, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5b19001fbdNjcDWzfDE5R", "created": 1790657190681, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 31, "version": 1}}}} +{"type": "emit_inbound", "label": "permission.asked.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5b1f002oOOqSBgv0ctxTY", "created": 1790657190687, "type": "permission.asked", "location": {"directory": ""}, "data": {"id": "per_0eb7c5b1f001zGfBgDhzxz8z10", "sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "action": "shell", "resources": ["echo SECOND"], "save": ["echo *"], "source": {"type": "tool", "messageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "id": "call_function_9notyrs0ad2u_1"}}}}} +{"type": "expect_outbound", "label": "session.synthetic.2", "frame": {"type": "session.synthetic", "input": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "text": "", "delivery": "steer", "resume": false}}} +{"type": "emit_inbound", "label": "session.synthetic.response.2", "frame": {"type": "sdk.response", "operation": "session.synthetic", "data": {"data": {"id": "msg_0f1fdc3b6001Deu0vB18PfvoxD", "sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "time": {"created": 1790766334903}, "type": "synthetic", "payload": {"text": "note"}, "delivery": "steer"}}}} +{"type": "expect_outbound", "label": "permission.reply.3", "frame": {"type": "permission.reply", "input": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "requestID": "per_0eb7c5b1f001zGfBgDhzxz8z10", "decision": "reject", "message": ""}}} +{"type": "emit_inbound", "label": "permission.reply.response.3", "frame": {"type": "sdk.response", "operation": "permission.reply", "data": null}} +{"type": "emit_inbound", "label": "permission.replied.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5b250013V8sUmFDQoT7Uk", "created": 1790657190693, "type": "permission.replied", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "requestID": "per_0eb7c5b1f001zGfBgDhzxz8z10", "reply": "reject"}}}} +{"type": "emit_inbound", "label": "session.tool.failed.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5b26001aon6ec5lwLlPx5", "created": 1790657190694, "type": "session.tool.failed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "id": "call_function_9notyrs0ad2u_1", "error": {"type": "tool.execution", "message": "Unable to execute command: echo SECOND"}, "executed": false}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 32, "version": 2}}}} +{"type": "emit_inbound", "label": "session.step.ended.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5b34001qvlcxRfUA2ekqA", "created": 1790657190708, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c53a4001Bmq7x3AMXAcOaM", "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 40, "output": 47, "reasoning": 30, "cache": {"read": 9346, "write": 0}}, "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": []}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 33, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5b38001TkcOiQ50eANUNd", "created": 1790657190712, "type": "session.usage.updated", "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "cost": 0, "tokens": {"input": 8834, "output": 117, "reasoning": 30, "cache": {"read": 19125, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.step.started.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5ec6001wMTa4RTm0RPgP2", "created": 1790657191622, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7c5b46001q2kQ1kv44gRI08", "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "started": 1790657190748}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 34, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5eca0010IBOklhA7C1fJv", "created": 1790657191626, "type": "session.reasoning.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c5b46001q2kQ1kv44gRI08", "ordinal": 0, "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 35, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c5f31001XxOE3Jnl9doYWN", "created": 1790657191729, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c5b46001q2kQ1kv44gRI08", "ordinal": 0, "delta": "Still"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c612a001oBRkXZoh3PbgrP", "created": 1790657192234, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c5b46001q2kQ1kv44gRI08", "ordinal": 0, "delta": " failing. Report honestly. Maybe try a slightly different form to see if it's the command or the tool? That could be useful"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c63530014KwG61StSw6myf", "created": 1790657192787, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c5b46001q2kQ1kv44gRI08", "ordinal": 0, "delta": " diagnostic info. Let me try one variation to distinguish \"tool broken\" from \"command specific\". Actually that's reasonable and"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.6", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c651d0019AHBkMPurBXF29", "created": 1790657193245, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c5b46001q2kQ1kv44gRI08", "ordinal": 0, "delta": " cheap.\n\nLet me try `echo THIRD` or a different command like `printf`. But careful: the user only asked for two commands. A single"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.7", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c66db001MmKnPeUeCJAPvg", "created": 1790657193691, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c5b46001q2kQ1kv44gRI08", "ordinal": 0, "delta": " diagnostic is helpful to characterize the failure. I'll do one probe."}}}} +{"type": "emit_inbound", "label": "session.tool.input.started.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c66dc001ONpwuAhXUZ4HGp", "created": 1790657193692, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c5b46001q2kQ1kv44gRI08", "id": "call_function_xbanzmf7lvs6_1", "name": "shell"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 36, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c6749001Hcvug3x3TKBh0a", "created": 1790657193801, "type": "session.reasoning.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c5b46001q2kQ1kv44gRI08", "ordinal": 0, "text": "Still failing. Report honestly. Maybe try a slightly different form to see if it's the command or the tool? That could be useful diagnostic info. Let me try one variation to distinguish \"tool broken\" from \"command specific\". Actually that's reasonable and cheap.\n\nLet me try `echo THIRD` or a different command like `printf`. But careful: the user only asked for two commands. A single diagnostic is helpful to characterize the failure. I'll do one probe.", "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 37, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c674c001cn8yL4Hs762p8W", "created": 1790657193804, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c5b46001q2kQ1kv44gRI08", "id": "call_function_xbanzmf7lvs6_1", "text": "{\"command\": \"echo THIRD_PROBE\"}"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 38, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c674e001cEvXMPrLuYu1Az", "created": 1790657193806, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c5b46001q2kQ1kv44gRI08", "id": "call_function_xbanzmf7lvs6_1", "input": {"command": "echo THIRD_PROBE"}, "executed": false}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 39, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c67500016lve9SMYgKDzBj", "created": 1790657193808, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c5b46001q2kQ1kv44gRI08"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 40, "version": 1}}}} +{"type": "emit_inbound", "label": "permission.asked.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c6755002kZ5fWIMV5aQKg8", "created": 1790657193813, "type": "permission.asked", "location": {"directory": ""}, "data": {"id": "per_0eb7c6755001DdRDvkLLSYU4BD", "sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "action": "shell", "resources": ["echo THIRD_PROBE"], "save": ["echo *"], "source": {"type": "tool", "messageID": "msg_0eb7c5b46001q2kQ1kv44gRI08", "id": "call_function_xbanzmf7lvs6_1"}}}}} +{"type": "expect_outbound", "label": "session.synthetic.3", "frame": {"type": "session.synthetic", "input": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "text": "", "delivery": "steer", "resume": false}}} +{"type": "emit_inbound", "label": "session.synthetic.response.3", "frame": {"type": "sdk.response", "operation": "session.synthetic", "data": {"data": {"id": "msg_0f1fdc3b6001Deu0vB18PfvoxD", "sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "time": {"created": 1790766334903}, "type": "synthetic", "payload": {"text": "note"}, "delivery": "steer"}}}} +{"type": "expect_outbound", "label": "permission.reply.4", "frame": {"type": "permission.reply", "input": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "requestID": "per_0eb7c6755001DdRDvkLLSYU4BD", "decision": "reject", "message": ""}}} +{"type": "emit_inbound", "label": "permission.reply.response.4", "frame": {"type": "sdk.response", "operation": "permission.reply", "data": null}} +{"type": "emit_inbound", "label": "permission.replied.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c675b001qM9kijSt04WDmq", "created": 1790657193819, "type": "permission.replied", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "requestID": "per_0eb7c6755001DdRDvkLLSYU4BD", "reply": "reject"}}}} +{"type": "emit_inbound", "label": "session.tool.failed.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c675c001Bmqb8YmgWcFtXo", "created": 1790657193820, "type": "session.tool.failed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c5b46001q2kQ1kv44gRI08", "id": "call_function_xbanzmf7lvs6_1", "error": {"type": "tool.execution", "message": "Unable to execute command: echo THIRD_PROBE"}, "executed": false}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 41, "version": 2}}}} +{"type": "emit_inbound", "label": "session.step.ended.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c6767001OUgZOesVWiOz53", "created": 1790657193831, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c5b46001q2kQ1kv44gRI08", "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 40, "output": 29, "reasoning": 95, "cache": {"read": 9461, "write": 0}}, "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": []}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 42, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c676a001ZGwOBj4nU1k0WP", "created": 1790657193834, "type": "session.usage.updated", "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "cost": 0, "tokens": {"input": 8874, "output": 146, "reasoning": 125, "cache": {"read": 28586, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.step.started.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c6c98001iQ206uAaYHTO8O", "created": 1790657195160, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ", "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "started": 1790657193870}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 43, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c6c9c001WZ5DaSqx56g5se", "created": 1790657195164, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ", "ordinal": 0}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 44, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.7", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c6d03001k811CO4cpTKHXM", "created": 1790657195267, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ", "ordinal": 0, "delta": "Here's"}}}} +{"type": "emit_inbound", "label": "session.text.delta.8", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c6ee3001tHjUh5L0jXj1Ae", "created": 1790657195747, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ", "ordinal": 0, "delta": " what happened:\n\n- **`echo FIRST`** — succeeded. Output: `FIRST`\n- **`echo SECOND`** — failed. Returned a tool-level error: `Unable to execute command: echo SECOND"}}}} +{"type": "emit_inbound", "label": "session.text.delta.9", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c70e1001w0xn5r3BZAJ0wj", "created": 1790657196258, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ", "ordinal": 0, "delta": "`. No stdout, no exit code, no stderr content (the error payload's `content` array was empty).\n- **Retry of `echo SECOND`** — same error"}}}} +{"type": "emit_inbound", "label": "session.text.delta.10", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c7258001ttVqrq24Ul7f3y", "created": 1790657196632, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ", "ordinal": 0, "delta": ".\n- **Diagnostic probe (`echo THIRD_PROBE`)** — also failed identically.\n\nSo the second command never ran. This isn't specific"}}}} +{"type": "emit_inbound", "label": "session.text.delta.11", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c7455001l4XFX2OvEVXscp", "created": 1790657197141, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ", "ordinal": 0, "delta": " to the string \"SECOND\" — every shell call after the first one failed at the same layer, which points to the shell"}}}} +{"type": "emit_inbound", "label": "session.text.delta.12", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c75d9001fAwvlhzM3Ng5l2", "created": 1790657197529, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ", "ordinal": 0, "delta": " tool itself breaking after the first invocation in this session rather than anything about the command.\n\nTo be clear about what I do *not* know: the error is a generic \""}}}} +{"type": "emit_inbound", "label": "session.text.delta.13", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c77a0001uLT0fah3fxfp4r", "created": 1790657197985, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ", "ordinal": 0, "delta": "unable to execute\" with no underlying cause, so I can't tell from this whether it's a sandbox/session issue"}}}} +{"type": "emit_inbound", "label": "session.text.delta.14", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c7905001UVnAKdR1TxVJeR", "created": 1790657198341, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ", "ordinal": 0, "delta": ", a tool-state issue, or something on the host. I also can't confirm from the first success that the environment is otherwise healthy,"}}}} +{"type": "emit_inbound", "label": "session.text.delta.15", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c7af3001wYRvqfCG6Ckn8y", "created": 1790657198835, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ", "ordinal": 0, "delta": " since that call predates the breakage.\n\nIf you want, I can try again shortly, or test the shell"}}}} +{"type": "emit_inbound", "label": "session.text.delta.16", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c7ca2001q6mUHeWL5XUr6b", "created": 1790657199266, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ", "ordinal": 0, "delta": " tool with a different working directory to narrow down whether it's path-related. Otherwise the tool likely needs a session restart to recover."}}}} +{"type": "emit_inbound", "label": "session.text.ended.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c7ca5001ZLYnzInoqAj6xi", "created": 1790657199269, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ", "ordinal": 0, "text": "Here's what happened:\n\n- **`echo FIRST`** — succeeded. Output: `FIRST`\n- **`echo SECOND`** — failed. Returned a tool-level error: `Unable to execute command: echo SECOND`. No stdout, no exit code, no stderr content (the error payload's `content` array was empty).\n- **Retry of `echo SECOND`** — same error.\n- **Diagnostic probe (`echo THIRD_PROBE`)** — also failed identically.\n\nSo the second command never ran. This isn't specific to the string \"SECOND\" — every shell call after the first one failed at the same layer, which points to the shell tool itself breaking after the first invocation in this session rather than anything about the command.\n\nTo be clear about what I do *not* know: the error is a generic \"unable to execute\" with no underlying cause, so I can't tell from this whether it's a sandbox/session issue, a tool-state issue, or something on the host. I also can't confirm from the first success that the environment is otherwise healthy, since that call predates the breakage.\n\nIf you want, I can try again shortly, or test the shell tool with a different working directory to narrow down whether it's path-related. Otherwise the tool likely needs a session restart to recover."}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 45, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c7caf001PmKcVzcfXw65Bt", "created": 1790657199279, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 46, "version": 1}}}} +{"type": "emit_inbound", "label": "project.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c7cbe001pNWecgWly1xpSY", "created": 1790657199294, "type": "project.updated", "location": {"directory": ""}, "data": {"id": "global", "canonical": "", "vcs": "git", "time": {"created": 1790656063718, "updated": 1790656063718, "active": 1790657199292}, "sandboxes": []}}}} +{"type": "emit_inbound", "label": "session.step.ended.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c7cc8001OuK5ORFGNBOgb0", "created": 1790657199304, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "assistantMessageID": "msg_0eb7c6776001Y0FNmLjmAnwypJ", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 43, "output": 260, "reasoning": 0, "cache": {"read": 9623, "write": 0}}, "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": []}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 47, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c7ccd001uI6a8FN82Y8cTQ", "created": 1790657199309, "type": "session.usage.updated", "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "cost": 0, "tokens": {"input": 8917, "output": 406, "reasoning": 125, "cache": {"read": 38209, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c7cd0001xyzv13ptdNO6Tz", "created": 1790657199312, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e"}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 48, "version": 1}}}} +{"type": "runtime_exit", "status": "success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission/output.ts new file mode 100644 index 000000000000..d914ec1fb52b --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission/output.ts @@ -0,0 +1,81 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_PERMISSION_PROMPT, + projectionFor, +} from "../shared.ts"; + +/** + * Supervised: each shell call asks. The approved one runs; the declined one and + * the model's two retries fail without running, and the turn still completes. + */ +export function assertOpenCode2PermissionOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertUserMessagesInclude(projection, [OPENCODE2_PERMISSION_PROMPT]); + + const requests = projection.runtimeRequests.toSorted((left, right) => + left.createdAt < right.createdAt ? -1 : 1, + ); + assert.deepEqual( + requests.map((request) => [request.kind, request.status, request.decision]), + [ + ["command", "resolved", "accept"], + ["command", "resolved", "decline"], + ["command", "resolved", "decline"], + ["command", "resolved", "decline"], + ], + ); + assert.deepEqual( + requests.map((request) => request.nativeRequestRef?.nativeId), + [ + "per_0eb7c4d7e001Pyt8o50Vi4KrOO", + "per_0eb7c5376001gkpJzQEeWtaoRE", + "per_0eb7c5b1f001zGfBgDhzxz8z10", + "per_0eb7c6755001DdRDvkLLSYU4BD", + ], + ); + + const approvals = projection.turnItems.filter((item) => item.type === "approval_request"); + assert.deepEqual( + approvals.map((item) => [item.status, item.type === "approval_request" ? item.prompt : null]), + [ + ["completed", "echo FIRST"], + ["cancelled", "echo SECOND"], + ["cancelled", "echo SECOND"], + ["cancelled", "echo THIRD_PROBE"], + ], + ); + // "Always" is this session's own rule, not OpenCode's project-wide grant. + const options = approvals[0]?.type === "approval_request" ? (approvals[0].options ?? []) : []; + assert.deepInclude(options, { + decision: "acceptForSession", + label: "Allow echo * this session", + }); + + const shells = projection.turnItems.filter((item) => item.type === "command_execution"); + assert.deepEqual( + shells.map((item) => [ + item.type === "command_execution" ? item.input : null, + item.status, + item.type === "command_execution" ? item.output : null, + ]), + [ + ["echo FIRST", "completed", "FIRST\n"], + ["echo SECOND", "failed", "Unable to execute command: echo SECOND"], + ["echo SECOND", "failed", "Unable to execute command: echo SECOND"], + ["echo THIRD_PROBE", "failed", "Unable to execute command: echo THIRD_PROBE"], + ], + ); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/input.ts new file mode 100644 index 000000000000..7f44b979bdc0 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/input.ts @@ -0,0 +1,11 @@ +import { OPENCODE2_QUESTION_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +/** The question tool asks one question with two options; the user types their own answer. */ +export function openCode2QuestionInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_QUESTION_PROMPT }, + { type: "answer_next_user_input_request", answers: { q0: "blue" } }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/opencode_transcript.ndjson new file mode 100644 index 000000000000..4b00932c5fc1 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/opencode_transcript.ndjson @@ -0,0 +1,40 @@ +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_question", "metadata": {"source": "opencode2-spike/recordings/question.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f14865e15ffewHnFwxdBlN6Eqc", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: the question tool's form is answered through T3."}} +{"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} +{"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": ""}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} +{"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a1e5001wy4Omf7ROvJv6K", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f14865e15ffewHnFwxdBlN6Eqc", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657012250, "updated": 1790657012250}, "title": "spike question", "location": {"directory": ""}, "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "subagent", "resource": "*", "effect": "deny"}]}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a219001VaS9PAEJESAl1s", "created": 1790657012250, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "slug": "misty-cactus", "version": "2.0.18", "projectID": "global", "location": {"directory": ""}, "subpath": "", "title": "spike question", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 0, "version": 1}}}} +{"type": "emit_inbound", "label": "project.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a223001QhSQm49nHEj9HB", "created": 1790657012259, "type": "project.updated", "data": {"id": "global", "canonical": "", "vcs": "git", "time": {"created": 1790656063718, "updated": 1790656063718, "active": 1790657012258}, "sandboxes": []}}}} +{"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb79a22f0011nQwzuIWmBQqu7", "sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "time": {"created": 1790657012275}, "type": "user", "payload": {"text": "Before doing anything, use the question tool to ask me which color I prefer, offering the options red and blue. After I answer, reply with only the chosen color."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a233001GMt8l3XrvXECbe", "created": 1790657012275, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb79a22f0011nQwzuIWmBQqu7", "sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "item": {"type": "user", "payload": {"text": "Before doing anything, use the question tool to ask me which color I prefer, offering the options red and blue. After I answer, reply with only the chosen color."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a239001jhWey2lm6uJEIG", "created": 1790657012281, "type": "session.execution.started", "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc"}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a258001oY26hWjDcDk90E", "created": 1790657012312, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "delta": {"core/environment": "cf2e0362a1ddd847a4e966bb3ac3633511b6293b1d4175d0283a2370d1bfee46", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a25c0010o7pUlTxZMtNi0", "created": 1790657012316, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "inboxID": "msg_0eb79a22f0011nQwzuIWmBQqu7"}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a851001ifRShG2QgsqkNj", "created": 1790657013841, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb79a277001Yxbds4JIg1DwOa", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657012379}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a85500184EdB1404FDr8Y", "created": 1790657013845, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79a277001Yxbds4JIg1DwOa", "ordinal": 0}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a8be001ctdYKtO47GrFZB", "created": 1790657013950, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79a277001Yxbds4JIg1DwOa", "ordinal": 0, "delta": "I'll ask you"}}}} +{"type": "emit_inbound", "label": "session.text.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a9c8001UI4zp2AgdgHOGq", "created": 1790657014216, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79a277001Yxbds4JIg1DwOa", "ordinal": 0, "delta": " now."}}}} +{"type": "emit_inbound", "label": "session.tool.input.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a9c90017d1QwzVfsUmETH", "created": 1790657014218, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79a277001Yxbds4JIg1DwOa", "id": "call_function_p1m69ex0bp70_1", "name": "question"}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ab19001AeC4GlT4FGtbS4", "created": 1790657014553, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79a277001Yxbds4JIg1DwOa", "id": "call_function_p1m69ex0bp70_1", "text": "{\"questions\": [{\"question\": \"Which color do you prefer?\", \"header\": \"Color preference\", \"options\": [{\"label\": \"Red\", \"description\": \"Choose the color red\"}, {\"label\": \"Blue\", \"description\": \"Choose the color blue\"}]}]}"}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ab1e001Hbhgf98jxEpjiH", "created": 1790657014558, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79a277001Yxbds4JIg1DwOa", "id": "call_function_p1m69ex0bp70_1", "input": {"questions": [{"question": "Which color do you prefer?", "header": "Color preference", "options": [{"label": "Red", "description": "Choose the color red"}, {"label": "Blue", "description": "Choose the color blue"}]}]}, "executed": false}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ab22001Jd3t8ygR2n76TR", "created": 1790657014562, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79a277001Yxbds4JIg1DwOa", "ordinal": 0, "text": "I'll ask you now."}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 10, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ab280012RgWRwUr5eYXop", "created": 1790657014568, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79a277001Yxbds4JIg1DwOa"}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "form.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ab37001Q6995MV6tF40k2", "created": 1790657014583, "type": "form.created", "location": {"directory": ""}, "data": {"form": {"id": "frm_0eb79ab35001fkvFECSh3wYNVD", "sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "title": "Questions", "metadata": {"kind": "question", "tool": {"messageID": "msg_0eb79a277001Yxbds4JIg1DwOa", "id": "call_function_p1m69ex0bp70_1"}}, "fields": [{"key": "q0", "title": "Color preference", "description": "Which color do you prefer?", "type": "string", "options": [{"value": "Red", "label": "Red", "description": "Choose the color red"}, {"value": "Blue", "label": "Blue", "description": "Choose the color blue"}], "custom": true}]}}}}} +{"type": "expect_outbound", "label": "session.form.reply", "frame": {"type": "session.form.reply", "input": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "formID": "frm_0eb79ab35001fkvFECSh3wYNVD", "answer": {"q0": "blue"}}}} +{"type": "emit_inbound", "label": "session.form.reply.response", "frame": {"type": "sdk.response", "operation": "session.form.reply", "data": null}} +{"type": "emit_inbound", "label": "form.replied", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ab42001HAm7XPp6bF2zV3", "created": 1790657014594, "type": "form.replied", "location": {"directory": ""}, "data": {"id": "frm_0eb79ab35001fkvFECSh3wYNVD", "sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "answer": {"q0": "blue"}}}}} +{"type": "emit_inbound", "label": "session.tool.success", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ab46001k0fkHGvkc9238e", "created": 1790657014598, "type": "session.tool.success", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79a277001Yxbds4JIg1DwOa", "id": "call_function_p1m69ex0bp70_1", "content": [{"type": "text", "text": "User has answered your questions: \"Which color do you prefer?\"=\"blue\". You can now continue with the user's answers in mind."}], "metadata": {"answers": [["blue"]], "truncated": false}, "executed": false}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 12, "version": 2}}}} +{"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ab5b001jJlpJoQtMO2fL6", "created": 1790657014619, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79a277001Yxbds4JIg1DwOa", "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 8772, "output": 128, "reasoning": 0, "cache": {"read": 487, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 13, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ab61001xysaJqhutY6dOn", "created": 1790657014625, "type": "session.usage.updated", "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "cost": 0, "tokens": {"input": 8772, "output": 128, "reasoning": 0, "cache": {"read": 487, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.step.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79b25e001WV69v9cH0Qu14p", "created": 1790657016415, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb79ab7a001RBCW3otDgQJ3x0", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657014686}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 14, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79b264001Isi6c4eVIKUJ0L", "created": 1790657016420, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79ab7a001RBCW3otDgQJ3x0", "ordinal": 0}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 15, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79b270001S3YAvAgy5Lc8p3", "created": 1790657016432, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79ab7a001RBCW3otDgQJ3x0", "ordinal": 0, "delta": "blue"}}}} +{"type": "emit_inbound", "label": "session.text.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79b2710017L1kbLn7a4x9WC", "created": 1790657016433, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79ab7a001RBCW3otDgQJ3x0", "ordinal": 0, "text": "blue"}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 16, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79b275001b2ABm0RKjwAEnP", "created": 1790657016437, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79ab7a001RBCW3otDgQJ3x0"}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 17, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79b28a0019CVZLwKLLGorWz", "created": 1790657016458, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "assistantMessageID": "msg_0eb79ab7a001RBCW3otDgQJ3x0", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 44, "output": 2, "reasoning": 0, "cache": {"read": 9385, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 18, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79b28f001Lzf0RmHIRgKdNc", "created": 1790657016463, "type": "session.usage.updated", "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "cost": 0, "tokens": {"input": 8816, "output": 130, "reasoning": 0, "cache": {"read": 9872, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79b291001Ju7g20ML6DmHxL", "created": 1790657016465, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc"}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 19, "version": 1}}}} +{"type": "runtime_exit", "status": "success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/output.ts new file mode 100644 index 000000000000..38771a1c13dc --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/output.ts @@ -0,0 +1,59 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertTurnItemTypeSequence, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_QUESTION_PROMPT, + projectionFor, +} from "../shared.ts"; + +/** + * The question tool's form is a user input request with the model's options and + * a free-form answer; the answer goes back to OpenCode and the turn uses it. + */ +export function assertOpenCode2QuestionOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertUserMessagesInclude(projection, [OPENCODE2_QUESTION_PROMPT]); + // The form is the item; OpenCode's own `question` tool call is not repeated. + assertTurnItemTypeSequence(projection, [ + "user_message", + "assistant_message", + "user_input_request", + "assistant_message", + "checkpoint", + ]); + assertAssistantTextIncludes(projection, "blue"); + + const [request] = projection.runtimeRequests; + assert.lengthOf(projection.runtimeRequests, 1); + assert.deepInclude(request, { kind: "user_input", status: "resolved" }); + assert.deepEqual(request?.answers, { q0: "blue" }); + + const item = projection.turnItems.find((candidate) => candidate.type === "user_input_request"); + assert.equal(item?.status, "completed"); + assert.deepEqual(item?.type === "user_input_request" ? item.questions : [], [ + { + id: "q0", + header: "Color preference", + question: "Which color do you prefer?", + options: [ + { label: "Red", description: "Choose the color red", value: "Red" }, + { label: "Blue", description: "Choose the color blue", value: "Blue" }, + ], + multiSelect: false, + allowCustomAnswer: true, + }, + ]); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index ffcf4a646fb1..fd0ad6acf995 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -71,6 +71,10 @@ export const OPENCODE2_TOOL_CALL_PROMPT = "Use the read tool to read hello.txt, then run the shell command `echo TOOL_OK` with the bash tool, then reply DONE."; export const OPENCODE2_INTERRUPT_PROMPT = "Run the shell command `sleep 60 && echo LATE` with the bash tool, then reply DONE."; +export const OPENCODE2_PERMISSION_PROMPT = + "Run the shell command `echo FIRST` with the bash tool. After it completes, run `echo SECOND` with the bash tool. Then reply with what happened."; +export const OPENCODE2_QUESTION_PROMPT = + "Before doing anything, use the question tool to ask me which color I prefer, offering the options red and blue. After I answer, reply with only the chosen color."; export const TURN_INTERRUPT_PROMPT = "Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."; export const TURN_INTERRUPT_MID_TOOL_PROMPT = @@ -534,6 +538,12 @@ export function materializeFixtureInput(input: { }), ); + // A run that asks several times stays busy between answers, so the next + // answer waits for its request instead of for the thread to go idle. + const answersNext = (stepIndex: number) => { + const next = input.fixtureInput.steps[stepIndex + 1]?.type; + return next === "approve_next_runtime_request" || next === "answer_next_user_input_request"; + }; for (const [stepIndex, step] of input.fixtureInput.steps.entries()) { switch (step.type) { case "message": @@ -686,7 +696,9 @@ export function materializeFixtureInput(input: { answers: step.answers, }; steps.push({ type: "advance_clock", duration: "1 millis" }); - steps.push({ type: "await_thread_idle", threadId: ids.threadId }); + if (!answersNext(stepIndex)) { + steps.push({ type: "await_thread_idle", threadId: ids.threadId }); + } break; case "approve_next_runtime_request": pushDispatch( @@ -715,7 +727,9 @@ export function materializeFixtureInput(input: { : { shellSnapshotKeyWhilePending: step.shellSnapshotKeyWhilePending }), }; steps.push({ type: "advance_clock", duration: "1 millis" }); - steps.push({ type: "await_thread_idle", threadId: ids.threadId }); + if (!answersNext(stepIndex)) { + steps.push({ type: "await_thread_idle", threadId: ids.threadId }); + } break; case "steer": messageIndex += 1; diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index a616cf9bc6eb..558eb3c6a790 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -556,7 +556,12 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { NodeAssert.equal(snapshot.status, "ready"); NodeAssert.equal(snapshot.version, "2.0.18"); - NodeAssert.deepEqual(snapshot.supportedRuntimeModes, ["full-access"]); + NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access", + ]); NodeAssert.deepEqual( snapshot.models.map((model) => model.slug), ["opencode/big-pickle", "opencode/space-bunny-free"], @@ -670,7 +675,12 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i NodeAssert.equal(snapshot.status, "warning"); NodeAssert.equal(snapshot.version, "2.0.18"); - NodeAssert.deepEqual(snapshot.supportedRuntimeModes, ["full-access"]); + NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access", + ]); NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 202cb75a2ea8..731410cbe97e 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -411,12 +411,12 @@ export const makeOpenCode2ModelLoader = ( }); /** - * OpenCode 2 only runs in Full access until approvals map onto its permission - * rules, so the snapshot offers that mode alone. + * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised: + * OpenCode has no reviewer that approves routine actions. */ const OPENCODE_2_PRESENTATION = { ...OPENCODE_PRESENTATION, - supportedRuntimeModes: ["full-access"], + supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"], } as const; function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities { From 7f19b4a9cd3bdc40eb43fedac50917e0ff87ffc4 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:26:35 -0700 Subject: [PATCH 27/34] feat(server): OpenCode 2 subagents and background wake-ups Carries pingdotgg/t3code#14474 oc2-6-subagents unchanged, squashed to one commit with Julius Marminge's authorship. Drop this layer once it merges into codex-turn-mapping. --- .../Adapters/OpenCode2AdapterV2.test.ts | 484 +++++- .../Adapters/OpenCode2AdapterV2.ts | 1433 ++++++++++++++--- ...penCode2OrchestratorV2.integration.test.ts | 16 +- .../OpenCode2OrchestratorV2.live.test.ts | 149 +- ...estratorReplayFixtures.integration.test.ts | 49 +- .../testkit/fixtures/index.ts | 38 + .../fixtures/opencode2_background/input.ts | 15 + .../opencode_transcript.ndjson | 67 + .../fixtures/opencode2_background/output.ts | 114 ++ .../fixtures/opencode2_subagent/input.ts | 5 + .../opencode_transcript.ndjson | 81 + .../fixtures/opencode2_subagent/output.ts | 74 + .../testkit/fixtures/shared.ts | 4 + 13 files changed, 2277 insertions(+), 252 deletions(-) create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background/opencode_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/opencode_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/output.ts diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 6d70c1cdadd7..44bb6a8958cd 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -19,6 +19,7 @@ import { type ProviderReplayEntry, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; @@ -33,6 +34,7 @@ import type { ProviderAdapterV2SessionRuntime, } from "../ProviderAdapter.ts"; import * as IdAllocator from "../IdAllocator.ts"; +import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; import { OPENCODE_2_STILL_STOPPING } from "./OpenCode2AdapterV2.ts"; import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; @@ -62,10 +64,7 @@ const event = (type: string, data: Record): ProviderReplayEntry const durable = { durable: { aggregateID: SESSION, seq: 1, version: 1 } }; /** The rules T3 gives every session it runs. */ -const t3Rules = [ - { action: "*", resource: "*", effect: "allow" }, - { action: "subagent", resource: "*", effect: "deny" }, -]; +const t3Rules = [{ action: "*", resource: "*", effect: "allow" }]; const sessionInfo = (overrides: Record = {}) => ({ id: SESSION, permissions: t3Rules, @@ -204,7 +203,6 @@ const supervisedRules = [ { action: "edit", resource: "*", effect: "ask" }, { action: "external_directory", resource: "*", effect: "ask" }, ...buildPaths, - { action: "subagent", resource: "*", effect: "deny" }, ]; // The first ask and question form the spike recorded (recordings/permission, question). @@ -420,26 +418,6 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); - it.effect("denies the subagent tool on the sessions it creates", () => - Effect.gen(function* () { - const runtime = yield* openCode2ReplayRuntime([ - ...opening, - out("session.create", { - location: { directory: WORK }, - model: { providerID: "opencode", id: "big-pickle" }, - permissions: t3Rules, - }), - replyData("session.create", sessionInfo()), - ]); - const thread = yield* runtime.ensureThread({ - threadId, - modelSelection: bigPickle, - runtimePolicy: policy(), - }); - assert.equal(thread.nativeThreadRef?.nativeId, SESSION); - }).pipe(Effect.scoped), - ); - it.effect("stops running turns on an external server when the session closes", () => Effect.gen(function* () { const scope = yield* Scope.make(); @@ -479,6 +457,349 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); + /** A subagent's session as 2.0.18 announced it in the spike's `background` run. */ + const childCreated = (child: string) => ({ + sessionID: child, + slug: "proud-canyon", + version: "2.0.18", + projectID: "global", + parentID: SESSION, + location: { directory: WORK }, + subpath: "", + title: "Sleep", + agent: "general", + model: { id: "big-pickle", providerID: "opencode", variant: "default" }, + }); + /** A turn whose model launched a background subagent that still runs. */ + const backgroundLaunch = (child: string): ReadonlyArray => { + const call = "call-background"; + const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: call }; + return [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + event("session.tool.input.started", { ...tool, name: "subagent" }), + event("session.tool.called", { + ...tool, + name: "subagent", + input: { description: "Sleep", prompt: "sleep", background: true }, + executed: false, + }), + event("session.created", childCreated(child)), + event("session.tool.progress", { + ...tool, + metadata: { sessionID: child, status: "running" }, + }), + ]; + }; + // The subagent's child thread hangs off the app thread's lineage. + const withLineage = (thread: OrchestrationV2ProviderThread) => ({ + ...turnInput(thread), + appThread: { + id: threadId, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + } as OrchestrationV2AppThread, + }); + const CHILD = "ses_f1485c529ffea4URrYruwEg0Ja"; + /** + * The single reader of the runtime's events: resolves `attached` once the + * running background child has its thread, then returns the turn's terminal. + */ + const watchBackgroundTurn = (runtime: ProviderAdapterV2SessionRuntime) => + Effect.gen(function* () { + const attached = yield* Deferred.make(); + const terminal = yield* runtime.events.pipe( + Stream.tap((event) => + event.type === "subagent.updated" && event.subagent.childThreadId !== null + ? Deferred.succeed(attached, undefined) + : Effect.void, + ), + Stream.filter( + (event): event is Extract => + event.type === "turn.terminal", + ), + Stream.runHead, + Effect.map(Option.getOrUndefined), + Effect.forkScoped, + ); + return { attached: Deferred.await(attached), terminal: Fiber.join(terminal) }; + }); + it.effect("keeps background subagents running when a turn is interrupted to restart it", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + ...backgroundLaunch(CHILD), + // Only the parent's execution is stopped. + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + event("session.execution.interrupted", { sessionID: SESSION }), + ]); + const watch = yield* watchBackgroundTurn(runtime); + yield* runtime.startTurn(withLineage(thread)); + yield* watch.attached; + // The orchestrator's restart interrupt: no `requestRuntimeRestart`. + yield* runtime.interruptTurn({ + providerThread: thread, + providerTurnId: yield* providerTurnId, + }); + assert.equal((yield* watch.terminal)?.status, "interrupted"); + assert.isTrue(yield* runtime.hasPendingBackgroundWork!); + }).pipe(Effect.scoped), + ); + + it.effect("stops background subagents on a user Stop", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + ...backgroundLaunch(CHILD), + out("session.interrupt", { sessionID: CHILD }), + reply("session.interrupt", { interrupted: true }), + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + event("session.execution.interrupted", { sessionID: SESSION }), + ]); + const watch = yield* watchBackgroundTurn(runtime); + yield* runtime.startTurn(withLineage(thread)); + yield* watch.attached; + yield* runtime.interruptTurn({ + providerThread: thread, + providerTurnId: yield* providerTurnId, + requestRuntimeRestart: true, + }); + assert.equal((yield* watch.terminal)?.status, "interrupted"); + assert.isFalse(yield* runtime.hasPendingBackgroundWork!); + }).pipe(Effect.scoped), + ); + + it.effect("stops a background subagent announced before its call named it", () => + Effect.gen(function* () { + const call = "call-background"; + const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: call }; + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + event("session.tool.input.started", { ...tool, name: "subagent" }), + event("session.tool.called", { + ...tool, + name: "subagent", + input: { description: "Sleep", prompt: "sleep", background: true }, + executed: false, + }), + // OpenCode announces the child before the call's progress names it. + event("session.created", childCreated(CHILD)), + // Emitted after the announcement, so the Stop comes after it too. + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_assistant", + ordinal: 0, + text: "Launched.", + }), + out("session.interrupt", { sessionID: CHILD }), + reply("session.interrupt", { interrupted: true }), + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + event("session.execution.interrupted", { sessionID: SESSION }), + ]); + const announced = yield* Deferred.make(); + const terminal = yield* runtime.events.pipe( + Stream.tap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? Deferred.succeed(announced, undefined) + : Effect.void, + ), + Stream.filter( + (event): event is Extract => + event.type === "turn.terminal", + ), + Stream.runHead, + Effect.map(Option.getOrUndefined), + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(announced); + yield* runtime.interruptTurn({ + providerThread: thread, + providerTurnId: yield* providerTurnId, + requestRuntimeRestart: true, + }); + assert.equal((yield* Fiber.join(terminal))?.status, "interrupted"); + assert.isFalse(yield* runtime.hasPendingBackgroundWork!); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a finished background subagent's queued report as pending work", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + ...backgroundLaunch(CHILD), + event("session.execution.succeeded", { sessionID: SESSION }), + // The child ends; OpenCode queues its report for the parent, and only + // then starts the parent's follow-up execution. + event("session.execution.succeeded", { sessionID: CHILD }), + event("session.inbox.enqueued", { + inboxID: "msg_report", + sessionID: SESSION, + item: { + type: "synthetic", + payload: { + text: `\nCHILD_OK\n`, + description: "Sleep", + metadata: { + source: "subagent", + childID: CHILD, + agent: "General", + state: "completed", + }, + }, + delivery: "steer", + }, + }), + // Emitted once the report is in, so the check below runs in the gap. + event("session.usage.updated", { sessionID: SESSION }), + ]); + const reported = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.tap((event) => + event.type === "subagent.updated" && event.subagent.status === "completed" + ? Deferred.succeed(reported, undefined) + : Effect.void, + ), + Stream.runDrain, + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(reported); + // The follow-up execution OpenCode will start for the report is still to come. + assert.isTrue(yield* runtime.hasPendingBackgroundWork!); + assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + }).pipe(Effect.scoped), + ); + + it.effect("stops a nested background subagent's report on its own parent's session", () => + Effect.gen(function* () { + const MIDDLE = "ses_middle0000000000000000000"; + const GRANDCHILD = "ses_grandchild00000000000000"; + const tool = (session: string, id: string) => ({ + sessionID: session, + assistantMessageID: `msg_assistant_${id}`, + id, + }); + const report = { + inboxID: "msg_nested_report", + sessionID: MIDDLE, + item: { + type: "synthetic", + payload: { + text: `\n`, + description: "Deep", + metadata: { + source: "subagent", + childID: GRANDCHILD, + agent: "General", + state: "cancelled", + }, + }, + delivery: "steer", + }, + }; + const offered = yield* Deferred.make(); + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + // The thread's model starts a background subagent... + event("session.tool.input.started", { ...tool(SESSION, "call-middle"), name: "subagent" }), + event("session.tool.called", { + ...tool(SESSION, "call-middle"), + name: "subagent", + input: { description: "Middle", prompt: "delegate", background: true }, + executed: false, + }), + event("session.created", { ...childCreated(MIDDLE), title: "Middle" }), + event("session.tool.progress", { + ...tool(SESSION, "call-middle"), + metadata: { sessionID: MIDDLE, status: "running" }, + }), + event("session.execution.started", { sessionID: MIDDLE }), + // ...which starts one of its own in the background. + event("session.tool.input.started", { ...tool(MIDDLE, "call-deep"), name: "subagent" }), + event("session.tool.called", { + ...tool(MIDDLE, "call-deep"), + name: "subagent", + input: { description: "Deep", prompt: "sleep", background: true }, + executed: false, + }), + event("session.created", { + ...childCreated(GRANDCHILD), + parentID: MIDDLE, + title: "Deep", + }), + event("session.tool.progress", { + ...tool(MIDDLE, "call-deep"), + metadata: { sessionID: GRANDCHILD, status: "running" }, + }), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_assistant_root", + ordinal: 0, + text: "Launched.", + }), + // A Stop on the thread interrupts both background subagents. + out("session.interrupt", { sessionID: MIDDLE }), + reply("session.interrupt", { interrupted: true }), + out("session.interrupt", { sessionID: GRANDCHILD }), + reply("session.interrupt", { interrupted: true }), + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + event("session.execution.interrupted", { sessionID: SESSION }), + event("session.execution.interrupted", { sessionID: MIDDLE }), + // OpenCode still reports the stopped grandchild to the middle session + // and wakes it; that execution is stopped, not run as a subagent turn. + event("session.inbox.enqueued", report), + event("session.execution.started", { sessionID: MIDDLE }), + out("session.interrupt", { sessionID: MIDDLE }), + reply("session.interrupt", { interrupted: true }), + event("session.execution.interrupted", { sessionID: MIDDLE }), + // Later OpenCode runs the thread's own session by itself: a follow-up + // T3 offers a turn for, which marks that everything above was handled. + event("session.execution.started", { sessionID: SESSION }), + ]).pipe( + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), + take: Effect.never, + }), + ); + const launched = yield* Deferred.make(); + const middleTurns: Array = []; + yield* runtime.events.pipe( + Stream.tap((event) => + Effect.gen(function* () { + if ( + event.type === "provider_turn.updated" && + event.providerTurn.nativeTurnRef?.nativeId?.startsWith(`${MIDDLE}:turn:`) === true + ) { + middleTurns.push(event.providerTurn.nativeTurnRef.nativeId); + } + if (event.type === "turn_item.updated" && event.turnItem.type === "assistant_message") { + yield* Deferred.succeed(launched, undefined); + } + }), + ), + Stream.runDrain, + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(launched); + yield* runtime.interruptTurn({ + providerThread: thread, + providerTurnId: yield* providerTurnId, + requestRuntimeRestart: true, + }); + // The report wakes the middle session; the adapter stops that execution + // (the replay fails on any other request) and opens no turn for it. + yield* Deferred.await(offered); + assert.deepEqual([...new Set(middleTurns)], [`${MIDDLE}:turn:1`]); + }).pipe(Effect.scoped), + ); + /** A prompt accepted, then a Stop the server never answers, advanced past its timeout. */ const stopTimedOut: ReadonlyArray = [ out("session.prompt", { sessionID: SESSION, text: "" }), @@ -724,10 +1045,15 @@ describe("OpenCode2 adapter", () => { const runtime = yield* openCode2ReplayRuntime([ ...opening, out("session.get", { sessionID: SESSION }), - // Made before the subagent rule: it still allows everything. + // Made by an earlier build that denied subagents; resuming drops the deny. replyData( "session.get", - sessionInfo({ permissions: [{ action: "*", resource: "*", effect: "allow" }] }), + sessionInfo({ + permissions: [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, + ], + }), ), ...noOpenRequests, out("session.update", { sessionID: SESSION, permissions: t3Rules }), @@ -1038,6 +1364,110 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); + /** + * A supervised foreground subagent that asks to run `echo FIRST`: the + * child is announced and named by the parent's call, then asks. + */ + const subagentAsks = (child: string): ReadonlyArray => { + const call = "call-subagent"; + const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: call }; + return [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + event("session.tool.input.started", { ...tool, name: "subagent" }), + event("session.tool.called", { + ...tool, + name: "subagent", + input: { description: "Echo", prompt: "echo" }, + executed: false, + }), + event("session.created", { ...childCreated(child), title: "Echo" }), + event("session.tool.progress", { + ...tool, + metadata: { sessionID: child, status: "running" }, + }), + event("session.execution.started", { sessionID: child }), + event("permission.asked", { ...shellAsk.data, sessionID: child }), + ]; + }; + + it.effect("keeps a subagent's 'allow this session' in the subagent's own rules", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed( + [ + ...subagentAsks(CHILD), + // The subagent's session asked, so the grant is its rule, not the parent's. + // Supervised rules for the subagent's own agent (`general`, which lists + // no path rules here) with the grant. + out("session.update", { + sessionID: CHILD, + permissions: [ + ...supervisedRules.slice(0, 3), + { action: "shell", resource: "echo *", effect: "allow" }, + ], + }), + reply("session.update", null), + out("permission.reply", { + sessionID: CHILD, + requestID: shellAsk.data.id, + decision: "once", + }), + reply("permission.reply", null), + event("session.execution.succeeded", { sessionID: CHILD }), + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { supervised: true }, + ); + const requested = yield* requestOf(runtime).pipe(Effect.forkScoped); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn({ + ...withLineage(thread), + runtimePolicy: policy("approval-required"), + }); + const request = yield* Fiber.join(requested); + yield* runtime.respondToRuntimeRequest({ + requestId: request!.id, + decision: "acceptForSession", + }); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + }).pipe(Effect.scoped), + ); + + it.effect("stops the subagent whose request's answer cannot be delivered", () => + Effect.gen(function* () { + const replyOut = out("permission.reply", { + sessionID: CHILD, + requestID: shellAsk.data.id, + decision: "once", + }); + const failedReply = reply("permission.reply", { + status: 500, + body: { _tag: "UnknownError", message: "reply failed" }, + }); + const { runtime, thread } = yield* resumed( + [ + ...subagentAsks(CHILD), + replyOut, + failedReply, + replyOut, + failedReply, + // The subagent's session is the one waiting on the answer. + out("session.interrupt", { sessionID: CHILD }), + reply("session.interrupt", { interrupted: true }), + ], + { supervised: true }, + ); + const requested = yield* requestOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn({ + ...withLineage(thread), + runtimePolicy: policy("approval-required"), + }); + const request = yield* Fiber.join(requested); + yield* runtime.respondToRuntimeRequest({ requestId: request!.id, decision: "accept" }); + }).pipe(Effect.scoped), + ); + it.effect("keeps 'allow this session' in the session's rules, not OpenCode's saved grants", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed( diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index ab8a3621a326..4ea6b997e1ae 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -6,9 +6,13 @@ * A turn is one `session.prompt`; the session's next `session.execution.*` * terminal ends it. Each runtime mode is a set of session permission rules, * and OpenCode's permission asks and question forms become runtime requests - * on the asking session's thread. Subagents, steering, fork, rollback and - * compaction arrive in later layers: the `subagent` tool is denied and the - * capabilities below say no. + * on the asking session's thread, a subagent's on its parent's. + * + * A `subagent` call runs in a child session shown as a subagent thread. A + * background one outlives its turn; when it ends, OpenCode starts a parent + * execution T3 did not ask for, which waits here for the continuation turn T3 + * opens for it. Steering, fork, rollback and compaction arrive in later + * layers, and the capabilities below say no. * * @module orchestration-v2/Adapters/OpenCode2AdapterV2 */ @@ -22,28 +26,35 @@ import { Session, type OpenCodeEvent, } from "@opencode/client/effect"; -import type { - OrchestrationV2ConversationMessage, - OrchestrationV2ExecutionNode, - OrchestrationV2ProviderCapabilities, - OrchestrationV2ProviderSession, - OrchestrationV2ProviderThread, - OrchestrationV2ProviderTurn, - OrchestrationV2RuntimeRequest, - OrchestrationV2TurnItem, - OrchestrationV2UserInputQuestion, - ProviderApprovalDecision, - ProviderInstanceId, - RuntimeRequestId, +import { + isOrchestrationV2WorkActive, + type OrchestrationV2AppThread, + type OrchestrationV2ConversationMessage, + type OrchestrationV2ExecutionNode, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2RuntimeRequest, + type OrchestrationV2Subagent, + type OrchestrationV2TurnItem, + type OrchestrationV2UserInputQuestion, + type ModelSelection, + type ProviderApprovalDecision, + type ProviderInstanceId, + type RunId, + type RuntimeRequestId, } from "@t3tools/contracts"; import type * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as ServerConfig from "../../config.ts"; @@ -59,7 +70,14 @@ import { causeErrorTag } from "@t3tools/shared/observability"; import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; import * as IdAllocator from "../IdAllocator.ts"; +import { backgroundWorkNotification, type BackgroundWorkReport } from "../Notification.ts"; +import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; import { makeProviderFailure } from "../ProviderFailure.ts"; +import { + makeSubagentChildThread, + makeSubagentConversationArtifacts, + subagentThreadTitle, +} from "../SubagentProjection.ts"; import * as ProviderAdapter from "../ProviderAdapter.ts"; import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; import { OPENCODE_PROVIDER, openCodePermissionRequestKind } from "./OpenCodeAdapterV2.ts"; @@ -116,7 +134,7 @@ const OpenCode2ProviderCapabilities = { supportsApplyPatchApproval: true, approvalsHaveNativeRequestIds: true, approvalCallbacksAreLiveOnly: true, - approvalsCanOriginateFromSubagents: false, + approvalsCanOriginateFromSubagents: true, }, planning: { emitsPlanUpdated: false, @@ -126,10 +144,10 @@ const OpenCode2ProviderCapabilities = { planDeltasHaveItemIds: false, }, subagents: { - supportsSubagents: false, - exposesSubagentThreadIds: false, - emitsSubagentLifecycle: false, - canWaitForSubagents: false, + supportsSubagents: true, + exposesSubagentThreadIds: true, + emitsSubagentLifecycle: true, + canWaitForSubagents: true, canCloseSubagents: false, canForkSubagentThread: false, }, @@ -163,8 +181,18 @@ const OpenCode2ProviderCapabilities = { type EventOf = Extract; type Tokens = EventOf<"session.step.ended">["data"]["tokens"]; +/** What a turn reports against: a run's own turn, or a subagent session's runless one. */ +interface TurnOwner { + readonly appThread: OrchestrationV2AppThread; + readonly threadId: OrchestrationV2AppThread["id"]; + readonly runId: RunId | null; + readonly runOrdinal: number; + readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"]; + readonly modelSelection: ModelSelection; +} + interface ActiveTurn { - readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; + readonly input: TurnOwner; readonly providerTurn: OrchestrationV2ProviderTurn; /** Open text and reasoning blocks, keyed `::`. */ readonly texts: Map; @@ -189,6 +217,50 @@ interface ActiveTurn { * own `session.execution.started` belongs to it. */ awaitingStart: boolean; + /** + * Prefixes a subagent session's tool ids in native item ids: the model + * names tool calls, so a child's could repeat its parent's. + */ + readonly scope: string; + /** Set once the turn calls `subagent`: its usage then leaves out the subagents'. */ + usedSubagents: boolean; +} + +/** A `subagent` tool call. A background one outlives the turn that made it. */ +interface SubagentCall { + readonly toolId: string; + readonly nativeId: string; + /** The calling session and the turn whose item the call is. */ + readonly state: ThreadState; + readonly turn: ActiveTurn; + readonly ordinal: number; + readonly startedAt: DateTime.Utc; + prompt: string; + title: string | null; + agent: string | undefined; + model: string | null; + /** The tool returned while the subagent runs on; the report OpenCode gives its parent settles it. */ + background: boolean; + child: ThreadState | undefined; + status: OrchestrationV2Subagent["status"]; + result: string | null; + completedAt: DateTime.Utc | null; +} + +/** + * An execution OpenCode ran on a thread's session without T3 asking: the + * parent's answer once a background subagent ended. Its events are held until + * the continuation turn T3 opens for it takes them. + */ +interface Wake { + readonly events: Array; + running: boolean; + /** The background subagents whose end it answers, as its turn's notification names them. */ + readonly reports: Array; + /** What OpenCode told the model, the continuation's prompt text. */ + readonly detail: string | null; + /** Stopped, or taken by a user turn: the continuation it asked for is not needed. */ + dropped: boolean; } interface OpenBlock { @@ -221,8 +293,42 @@ interface ThreadState { policy: RulesPolicy; /** "Allow … this session" answers, kept in the session's rules while T3 has it open. */ readonly grants: Array; + /** The session's `subagent` calls still running, by tool call id. */ + readonly calls: Map; + /** + * Set on a subagent's session: the call that runs it and the thread it + * shows in. Each of its executions is a runless turn there. + */ + readonly subagent: + | { + call: SubagentCall; + readonly appThread: OrchestrationV2AppThread; + turns: number; + /** The prompt its next execution answers, shown as that turn's user message. */ + prompt: string | undefined; + } + | undefined; + /** Executions OpenCode started on its own, oldest first, each waiting for its turn. */ + readonly wakes: Array; + /** + * Background subagents whose end OpenCode queued for this session and has + * not delivered yet, by inbox id. The wake that delivers them names them. + */ + readonly reports: Map< + string, + { readonly childId: string; readonly report: BackgroundWorkReport; readonly text: string } + >; + /** + * Background subagent sessions T3 stopped. OpenCode wakes the parent to + * report them; a wake that reports only these is stopped as well. + */ + readonly stoppedChildren: Set; } +type TurnTerminal = + | { readonly status: "completed" | "interrupted" } + | { readonly status: "failed"; readonly failure: ReturnType }; + type Rule = Permission.Rule; type RulesPolicy = Pick< ProviderAdapter.ProviderAdapterV2RuntimePolicy, @@ -235,6 +341,7 @@ interface PendingRequest { readonly request: OrchestrationV2RuntimeRequest; readonly item: OrchestrationV2TurnItem; readonly node: OrchestrationV2ExecutionNode; + /** The thread the request is shown on: the asking session's, or its parent's. */ readonly state: ThreadState; readonly turn: ActiveTurn; /** The session that asked: the thread's own, or one of its subagents'. */ @@ -278,8 +385,6 @@ const sessionRules = ( // are never denied: the free tier refuses sessions whose rules deny them. ...(policy.interactionMode === "plan" ? [rule("edit", "deny")] : []), ...paths, - // A background child wakes its parent in a turn T3 would not see. - rule("subagent", "deny"), ]; const sameRules = (left: ReadonlyArray | undefined, right: ReadonlyArray) => @@ -416,6 +521,28 @@ const sessionIdOf = (providerThread: OrchestrationV2ProviderThread) => { const textOf = (content: ReadonlyArray<{ readonly type: string; readonly text?: string }>) => content.flatMap((part) => (part.type === "text" && part.text ? [part.text] : [])).join("\n"); +const stringField = (record: Readonly> | undefined, key: string) => { + const value = record?.[key]; + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +}; + +/** How a background subagent ended, from the `state` of OpenCode's report to its parent. */ +const reportOutcome = (state: string | undefined) => + state === "completed" + ? ("completed" as const) + : state === "error" + ? ("failed" as const) + : state === "cancelled" + ? ("cancelled" as const) + : ("unknown" as const); + +/** A subagent's answer, without the `` wrapper OpenCode gives the model. */ +const subagentOutput = (text: string) => + /^]*>\n?([\s\S]*?)\n?<\/subagent>$/.exec(text.trim())?.[1] ?? text; + +const isContinuation = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => + turnInput.message.createdBy === "agent" && turnInput.message.creationSource === "provider"; + const INTERRUPT_TIMEOUT = "10 seconds"; const ACTIVE_CHECK_TIMEOUT = "5 seconds"; /** Answers that mean the server refused a prompt; any other failure may have been accepted. */ @@ -465,13 +592,16 @@ const sameModel = (left: ModelRef, right: ModelRef | undefined) => left.id === right?.id && (left.variant ?? "default") === (right?.variant ?? "default"); -/** The turn's own tokens: steps add up, and the last step's input is the live context size. */ +/** + * The turn's own tokens: steps add up, and the last step's input is the live + * context size. A subagent's tokens are its own session's, never these. + */ const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["status"]) => turn.steps === 0 ? { usageScope: "main_agent" as const, usageStatus: "unavailable" as const, - hasSubagents: false, + hasSubagents: turn.usedSubagents, } : { usageScope: "main_agent" as const, @@ -481,7 +611,7 @@ const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["s cacheCreationTokens: turn.usage.cacheWrite, outputTokens: turn.usage.output, reasoningTokens: turn.usage.reasoning, - hasSubagents: false, + hasSubagents: turn.usedSubagents, }; /** @@ -492,6 +622,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const server = yield* OpenCode2Server.OpenCode2Server; const idAllocator = yield* IdAllocator.IdAllocatorV2; const serverConfig = yield* ServerConfig.ServerConfig; + const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const driver = OPENCODE_PROVIDER; // Context windows by `provider/model`, from the latest `/api/model` read. const contextWindows = new Map(); @@ -516,13 +647,98 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr lastError: null, }; const events = yield* Queue.unbounded(); + // Thread sessions, and the sessions of their subagents. const threads = new Map(); // A subagent's session, by its id, to the thread whose session started it. const childOwners = new Map(); + // What OpenCode announced about a subagent's session, until its call names it. + const announced = new Map["data"]>(); + // Sessions of these threads with an execution running, seen on the stream. + const busy = new Set(); const pending = new Map(); + // Events and a wake's replay into its turn are handled one at a time, in order. + const lock = yield* Semaphore.make(1); const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); - const ownerOf = (sessionId: string) => threads.get(sessionId) ?? childOwners.get(sessionId); + const ownerOf = (sessionId: string) => childOwners.get(sessionId) ?? threads.get(sessionId); + + const newThreadState = ( + sessionId: string, + providerThread: OrchestrationV2ProviderThread, + directory: string, + subagent: ThreadState["subagent"], + ): ThreadState => ({ + sessionId, + providerThread, + providerTurns: new Map(), + active: undefined, + model: undefined, + unsettled: false, + directory, + agent: "build", + rules: undefined, + policy: input.runtimePolicy, + grants: [], + calls: new Map(), + subagent, + wakes: [], + reports: new Map(), + stoppedChildren: new Set(), + }); + + /** The thread whose session started this one, through any nesting. */ + const rootOf = (state: ThreadState): ThreadState => { + let current = state; + while (current.subagent !== undefined) current = current.subagent.call.state; + return current; + }; + + /** A thread's `subagent` calls still running, its subagents' included. */ + const runningCalls = (state: ThreadState): ReadonlyArray => + [...state.calls.values()].flatMap((call) => [ + call, + ...(call.child === undefined ? [] : runningCalls(call.child)), + ]); + + /** The `subagent` calls that lead to a session, from its own up to the thread's. */ + const callsAbove = (sessionId: string) => { + const calls: Array = []; + let current = threads.get(sessionId); + while (current?.subagent !== undefined) { + calls.push(current.subagent.call); + current = current.subagent.call.state; + } + return calls; + }; + + /** + * The turn a request from `sessionId` is shown under. A background + * subagent's goes to the turn that started it, whose run waits on the + * subagent; anything else goes to the thread's running turn. + */ + const requestTurn = (sessionId: string) => { + const state = ownerOf(sessionId); + if (state === undefined) return undefined; + const background = callsAbove(sessionId).findLast((call) => call.background); + if (background !== undefined) { + return isOrchestrationV2WorkActive(background.status) + ? { state, turn: background.turn } + : undefined; + } + return state.active === undefined ? undefined : { state, turn: state.active }; + }; + + /** + * Work that outlives the thread's turn: background subagents, held + * executions, and reports OpenCode queued for the follow-up it will start, + * on the thread's own session or on a subagent's for a nested one. + */ + const hasBackground = (state: ThreadState) => + state.wakes.length > 0 || + state.reports.size > 0 || + runningCalls(state).some( + (call) => call.background || (call.child !== undefined && call.child.reports.size > 0), + ); const setSessionStatus = ( status: OrchestrationV2ProviderSession["status"], @@ -679,12 +895,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const updatedAt = yield* DateTime.now; const startedAt = turn.startedAt.get(id) ?? updatedAt; const completedAt = status === "running" ? null : updatedAt; - yield* emitNode(state, turn, id, "tool_call", status, startedAt, completedAt); + const nativeId = `${turn.scope}${id}`; + yield* emitNode(state, turn, nativeId, "tool_call", status, startedAt, completedAt); yield* emit({ type: "turn_item.updated", driver, turnItem: openCodeToolTurnItem( - itemBase(state, turn, id, status, startedAt, completedAt, updatedAt), + itemBase(state, turn, nativeId, status, startedAt, completedAt, updatedAt), { name: tool.name, input: tool.input, @@ -709,19 +926,396 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); }; + const makeTurn = ( + owner: TurnOwner, + providerTurn: OrchestrationV2ProviderTurn, + options: { readonly scope: string; readonly awaitingStart: boolean }, + ): ActiveTurn => ({ + input: owner, + providerTurn, + texts: new Map(), + tools: new Map(), + startedAt: new Map(), + ordinals: new Map(), + nextOrdinal: providerTurn.ordinal * 100 + 1, + usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0 }, + steps: 0, + lastStep: undefined, + interrupted: false, + awaitingStart: options.awaitingStart, + scope: options.scope, + usedSubagents: false, + }); + + /** A subagent call as its parent turn's item, node and subagent row. */ + const emitSubagent = Effect.fnUntraced(function* (call: SubagentCall) { + const updatedAt = yield* DateTime.now; + const { turn, state } = call; + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver, + nativeItemId: call.nativeId, + }); + const completedAt = isOrchestrationV2WorkActive(call.status) + ? null + : (call.completedAt ?? updatedAt); + const childProviderThreadId = call.child?.providerThread.id ?? null; + const childThreadId = call.child?.subagent?.appThread.id ?? null; + yield* emit({ + type: "node.updated", + driver, + node: { + id: nodeId, + threadId: turn.input.threadId, + runId: turn.input.runId, + parentNodeId: turn.input.rootNodeId, + rootNodeId: turn.input.rootNodeId, + kind: "subagent", + status: call.status, + countsForRun: false, + providerThreadId: childProviderThreadId ?? state.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: ref(call.nativeId), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: call.startedAt, + completedAt, + }, + }); + yield* emit({ + type: "subagent.updated", + driver, + subagent: { + id: nodeId, + threadId: turn.input.threadId, + runId: turn.input.runId, + parentNodeId: turn.input.rootNodeId, + origin: "provider_native", + createdBy: "agent", + driver, + providerInstanceId: instanceId, + providerThreadId: childProviderThreadId, + childThreadId, + nativeTaskRef: ref(call.nativeId), + prompt: call.prompt, + title: call.title, + model: call.model, + status: call.status, + result: call.result, + startedAt: call.startedAt, + completedAt, + updatedAt, + }, + }); + yield* emit({ + type: "turn_item.updated", + driver, + turnItem: { + id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId: call.nativeId }), + threadId: turn.input.threadId, + runId: turn.input.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: ref(call.nativeId), + parentItemId: null, + ordinal: call.ordinal, + status: call.status, + title: call.title, + startedAt: call.startedAt, + completedAt, + updatedAt, + type: "subagent", + subagentId: nodeId, + origin: "provider_native", + driver, + providerInstanceId: instanceId, + childThreadId, + prompt: call.prompt, + result: call.result, + }, + }); + yield* syncRoster(rootOf(call.state)); + }); + + /** + * Lists a thread's background subagents on its provider thread, as typed + * `subagent` work: the thread shows it waits on them after its turn ends, + * and a Stop reaches them. Emitted only when the list changes. + */ + const syncRoster = Effect.fnUntraced(function* (state: ThreadState) { + const roster = runningCalls(state) + .filter((call) => call.background) + .map((call) => ({ + taskId: call.nativeId, + kind: "subagent" as const, + ...(call.title?.trim() ? { description: call.title.trim() } : {}), + ...(call.child?.subagent === undefined + ? {} + : { childThreadId: call.child.subagent.appThread.id }), + })); + const current = state.providerThread.pendingBackgroundTasks ?? []; + if ( + current.length === roster.length && + current.every( + (task, index) => + task.taskId === roster[index]?.taskId && + task.description === roster[index]?.description && + (task.kind === "subagent" ? task.childThreadId : undefined) === + roster[index]?.childThreadId, + ) + ) { + return; + } + state.providerThread = { + ...state.providerThread, + pendingBackgroundTasks: roster, + updatedAt: yield* DateTime.now, + }; + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: state.providerThread, + }); + }); + + /** + * Gives a subagent call its session once both are known: OpenCode names + * the session on the call's progress, and announces it just before. The + * session becomes a child thread under the call's item. + */ + const attachChild = Effect.fnUntraced(function* (call: SubagentCall, childId: string) { + if (call.child !== undefined) return; + const info = announced.get(childId); + announced.delete(childId); + const now = yield* DateTime.now; + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver, + nativeItemId: call.nativeId, + }); + const childThreadId = idAllocator.derive.threadFromProviderThread({ + driver, + nativeThreadId: childId, + providerInstanceId: instanceId, + }); + const providerThreadId = idAllocator.derive.providerThread({ + driver, + nativeThreadId: childId, + providerInstanceId: instanceId, + }); + if (info?.model !== undefined) call.model = `${info.model.providerID}/${info.model.id}`; + call.title = call.title ?? info?.title ?? null; + const parentThread = call.turn.input.appThread; + const appThread = makeSubagentChildThread({ + parentThread, + childThreadId, + parentNodeId: nodeId, + activeProviderThreadId: providerThreadId, + providerInstanceId: instanceId, + modelSelection: { + instanceId, + model: call.model ?? call.turn.input.modelSelection.model, + }, + title: subagentThreadTitle({ + parentTitle: parentThread.title, + title: call.title, + prompt: call.prompt, + ordinal: call.ordinal, + }), + now, + createdBy: "agent", + creationSource: "provider", + }); + const providerThread: OrchestrationV2ProviderThread = { + id: providerThreadId, + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: childThreadId, + ownerNodeId: nodeId, + nativeThreadRef: ref(childId), + nativeConversationHeadRef: null, + status: "active", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const child = newThreadState(childId, providerThread, call.state.directory, { + call, + appThread, + turns: 0, + prompt: call.prompt, + }); + child.agent = info?.agent ?? call.agent ?? child.agent; + threads.set(childId, child); + childOwners.set(childId, rootOf(call.state)); + call.child = child; + yield* emit({ type: "app_thread.created", driver, appThread }); + yield* emit({ type: "provider_thread.updated", driver, providerThread }); + yield* emitSubagent(call); + }); + + /** Ends a subagent call and every call under it that is still running. */ + const settleCall: ( + call: SubagentCall, + status: OrchestrationV2Subagent["status"], + result?: string, + ) => Effect.Effect = Effect.fnUntraced(function* (call, status, result) { + if (!isOrchestrationV2WorkActive(call.status)) return; + call.status = status; + call.completedAt = yield* DateTime.now; + if (result !== undefined) call.result = result; + call.state.calls.delete(call.toolId); + const child = call.child; + if (child !== undefined) { + // A snapshot: settling a call removes it from the map. + for (const nested of Array.from(child.calls.values())) { + yield* settleCall(nested, status === "completed" ? "interrupted" : status); + } + if (child.active !== undefined && status !== "completed") { + yield* finishTurn(child, { status: "interrupted" }); + } + child.providerThread = { + ...child.providerThread, + status: "idle", + updatedAt: call.completedAt, + }; + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: child.providerThread, + }); + } + yield* emitSubagent(call); + }); + + /** + * Opens the runless turn a subagent's session runs each execution in, on + * its child thread, with the prompt it answers as the turn's user message. + */ + const startChildTurn = Effect.fnUntraced(function* (child: ThreadState) { + const subagent = child.subagent; + if (subagent === undefined || child.active !== undefined) return; + subagent.turns += 1; + const startedAt = yield* DateTime.now; + const nativeTurnId = `${child.sessionId}:turn:${subagent.turns}`; + const rootNodeId = idAllocator.derive.nodeFromProviderItem({ + driver, + nativeItemId: `${nativeTurnId}:root`, + }); + const providerTurn: OrchestrationV2ProviderTurn = { + id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), + providerThreadId: child.providerThread.id, + nodeId: rootNodeId, + runAttemptId: null, + nativeTurnRef: ref(nativeTurnId, "weak"), + ordinal: subagent.turns, + status: "running", + startedAt, + completedAt: null, + }; + const turn = makeTurn( + { + appThread: subagent.appThread, + threadId: subagent.appThread.id, + runId: null, + runOrdinal: subagent.call.turn.input.runOrdinal, + rootNodeId, + modelSelection: subagent.appThread.modelSelection, + }, + providerTurn, + { scope: `${child.sessionId}:`, awaitingStart: false }, + ); + child.active = turn; + yield* emit({ + type: "node.updated", + driver, + node: { + id: rootNodeId, + threadId: turn.input.threadId, + runId: null, + parentNodeId: null, + rootNodeId, + kind: "root_turn", + status: "running", + countsForRun: false, + providerThreadId: child.providerThread.id, + providerTurnId: providerTurn.id, + nativeItemRef: ref(nativeTurnId, "weak"), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt: null, + }, + }); + yield* emitProviderTurn(child, turn, providerTurn); + const prompt = subagent.prompt; + subagent.prompt = undefined; + if (prompt !== undefined && prompt.length > 0) { + const nativeId = `${nativeTurnId}:prompt`; + const artifacts = makeSubagentConversationArtifacts({ + messageId: idAllocator.derive.messageFromProviderItem({ driver, nativeItemId: nativeId }), + senderThreadId: subagent.call.turn.input.threadId, + turnItemId: idAllocator.derive.turnItemFromProviderItem({ + driver, + nativeItemId: nativeId, + }), + threadId: turn.input.threadId, + rootNodeId, + providerThreadId: child.providerThread.id, + providerTurnId: providerTurn.id, + nativeItemRef: ref(nativeId, "weak"), + role: "user", + text: prompt, + ordinal: ordinalOf(turn, nativeId), + now: startedAt, + }); + yield* emit({ type: "message.updated", driver, message: artifacts.message }); + yield* emit({ type: "turn_item.updated", driver, turnItem: artifacts.turnItem }); + } + }); + + /** + * Asks the orchestrator for the continuation turn a held wake needs. It + * names the subagents whose end woke the thread. A wake a Stop or an + * earlier turn already took needs no turn, so its offer is dropped. + */ + const offerWake = Effect.fnUntraced(function* (state: ThreadState, wake: Wake) { + const route = state.providerThread.appThreadId; + if (route === null) return; + const notification = backgroundWorkNotification(wake.reports); + yield* continuationRequests.offer({ + threadId: route, + providerThreadId: state.providerThread.id, + driver, + detail: wake.detail, + ...(notification === null ? {} : { notification }), + dispatchIfCurrent: (dispatch) => + wake.dropped ? Effect.succeed(Option.none()) : Effect.map(dispatch, Option.some), + }); + }); + const finishTurn = Effect.fnUntraced(function* ( state: ThreadState, - terminal: - | { readonly status: "completed" | "interrupted" } - | { readonly status: "failed"; readonly failure: ReturnType }, + terminal: TurnTerminal, threadDisposition: "reusable" | "broken" = "reusable", ) { const turn = state.active; if (turn === undefined) return; state.active = undefined; - // OpenCode drops a request when its execution ends; so does the turn. + // OpenCode drops a request when the asking session's execution ends. A + // subagent's request shown on this turn ends with it too, unless the + // subagent runs in the background past a turn that did not fail. for (const entry of pending.values()) { - if (entry.turn === turn) yield* settleRequest(entry, "cancelled"); + const background = callsAbove(entry.sessionId).some((call) => call.background); + if ( + entry.sessionId === state.sessionId || + (entry.turn === turn && !(background && terminal.status !== "failed")) + ) { + yield* settleRequest(entry, "cancelled"); + } } const completedAt = yield* DateTime.now; // Blocks still open when the execution ends are final as they stand. @@ -736,6 +1330,38 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr terminal.status === "completed" ? "completed" : "interrupted", ); } + // A foreground subagent ends with the turn that waits on it. A background + // one outlives a finished or interrupted turn (a user Stop has already + // stopped it), and a failed turn stops it. + // A snapshot: settling a call removes it from the map. + for (const call of Array.from(state.calls.values())) { + if (call.turn !== turn) continue; + if (call.background && terminal.status !== "failed") continue; + yield* settleCall(call, terminal.status === "completed" ? "completed" : terminal.status); + } + if (state.subagent !== undefined) { + yield* emit({ + type: "node.updated", + driver, + node: { + id: turn.input.rootNodeId, + threadId: turn.input.threadId, + runId: null, + parentNodeId: null, + rootNodeId: turn.input.rootNodeId, + kind: "root_turn", + status: terminal.status, + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: turn.providerTurn.nativeTurnRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: turn.providerTurn.startedAt, + completedAt, + }, + }); + } const window = contextWindows.get(turn.input.modelSelection.model); const lastStep = turn.lastStep; yield* emitProviderTurn(state, turn, { @@ -758,6 +1384,15 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }, }), }); + // A subagent's session is its call's to settle: a turn there is runless, + // and T3 has no terminal to wait on. + if (state.subagent !== undefined) { + const call = state.subagent.call; + if (!call.background && terminal.status !== "completed") { + yield* settleCall(call, terminal.status); + } + return; + } state.providerThread = { ...state.providerThread, status: threadDisposition === "broken" ? "error" : "idle", @@ -768,7 +1403,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr driver, providerThread: state.providerThread, }); - const anyActive = [...threads.values()].some((candidate) => candidate.active !== undefined); + const anyActive = [...threads.values()].some( + (candidate) => candidate.subagent === undefined && candidate.active !== undefined, + ); yield* setSessionStatus(pending.size > 0 ? "waiting" : anyActive ? "running" : "ready", null); const base = { type: "turn.terminal" as const, @@ -935,9 +1572,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr readonly questions: ReadonlyArray; }, ) { - const state = ownerOf(sessionId); - const turn = state?.active; - if (state === undefined || turn === undefined) return; + const target = requestTurn(sessionId); + if (target === undefined) return; + const { state, turn } = target; if ([...pending.values()].some((entry) => entry.native.id === native.id)) return; const now = yield* DateTime.now; const requestId = yield* idAllocator.allocate.runtimeRequest({ @@ -1030,7 +1667,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const onPermissionAsked = Effect.fnUntraced(function* (event: EventOf<"permission.asked">) { const { data } = event; - const turn = ownerOf(data.sessionID)?.active; + // The asking session's own turn knows the tool: a subagent's session runs its own. + const turn = threads.get(data.sessionID)?.active; const toolName = data.source === undefined ? undefined : turn?.tools.get(data.source.id)?.name; const save = data.save ?? []; @@ -1062,8 +1700,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const onFormCreated = Effect.fnUntraced(function* (event: EventOf<"form.created">) { const { form } = event.data; - const state = ownerOf(form.sessionID); - if (state?.active === undefined) return; + const target = requestTurn(form.sessionID); + if (target === undefined) return; + const { state } = target; const mapped = formQuestions(form); if ("questions" in mapped) { return yield* showRequest( @@ -1091,98 +1730,56 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); }); - const handleEvent = Effect.fnUntraced(function* (event: OpenCode2StreamEvent) { - // The end of the run a timed-out Stop left behind; no turn is its own. - const endedSession = - event.type === "unreadable.execution.ended" - ? event.sessionID - : event.type === "session.execution.succeeded" || - event.type === "session.execution.failed" || - event.type === "session.execution.interrupted" - ? event.data.sessionID - : undefined; - const ended = endedSession === undefined ? undefined : threads.get(endedSession); - if (ended?.unsettled === true) { - ended.unsettled = false; - return; - } - // Only marks where a turn's own execution begins; it never ends one. - if (event.type === "unreadable.execution.started") { - const turn = threads.get(event.sessionID)?.active; - if (turn !== undefined) turn.awaitingStart = false; - return; - } - if (event.type === "unreadable.execution.ended") { - const state = threads.get(event.sessionID); - if (state === undefined || state.active?.awaitingStart === true) return; - return yield* finishTurn( - state, - event.executionType === "session.execution.succeeded" - ? { status: state.active?.interrupted === true ? "interrupted" : "completed" } - : event.executionType === "session.execution.interrupted" - ? { status: "interrupted" } - : { - status: "failed", - failure: makeProviderFailure({ - message: "OpenCode ended the turn with an error this version cannot read.", - class: "provider_error", - }), - }, - ); - } - // A subagent's requests are asked on the thread whose session started it. - if (event.type === "session.created" && event.data.parentID !== undefined) { - const owner = ownerOf(event.data.parentID); - if (owner !== undefined) childOwners.set(event.data.sessionID, owner); - return; - } - if (event.type === "permission.asked" || event.type === "form.created") { - const asking = - event.type === "permission.asked" ? event.data.sessionID : event.data.form.sessionID; - const state = ownerOf(asking); - if (state === undefined) return; - const turn = state.active; - if (turn !== undefined && !turn.awaitingStart) { - if (event.type === "permission.asked") return yield* onPermissionAsked(event); - return yield* onFormCreated(event); - } - // Asked by the run a Stop left behind, which nothing answers. - if (state.unsettled || turn?.awaitingStart === true) { - yield* stopStaleRequest( - asking, - event.type === "permission.asked" - ? { type: "permission", id: event.data.id } - : { type: "form", id: event.data.form.id }, - ); - } - return; - } - // Answered in another OpenCode client, or dropped by OpenCode: a reject - // it sends on its own (a Stop, or another reject in the same session) - // cancels the request. T3's own answers are settled where they are sent. - if ( - event.type === "permission.replied" || - event.type === "form.replied" || - event.type === "form.cancelled" - ) { - const nativeId = event.type === "permission.replied" ? event.data.requestID : event.data.id; - const entry = [...pending.values()].find((candidate) => candidate.native.id === nativeId); - if (entry === undefined || entry.answering) return; - const answered = - event.type === "form.replied" || - (event.type === "permission.replied" && event.data.reply !== "reject"); - return yield* settleRequest(entry, answered ? "resolved" : "cancelled"); - } - if (!("sessionID" in event.data) || typeof event.data.sessionID !== "string") return; - const state = threads.get(event.data.sessionID); - const turn = state?.active; - if (state === undefined || turn === undefined) return; - // A session runs one execution at a time, and each opens with `started` - // on this ordered stream, so what comes before it is the stopped run's. - if (turn.awaitingStart) { - if (event.type === "session.execution.started") turn.awaitingStart = false; - return; - } + const sessionOfEvent = (event: OpenCode2StreamEvent) => + event.type === "unreadable.execution.ended" || event.type === "unreadable.execution.started" + ? event.sessionID + : "sessionID" in event.data && typeof event.data.sessionID === "string" + ? event.data.sessionID + : undefined; + + const executionEnd = (type: string) => + type === "session.execution.succeeded" || + type === "session.execution.failed" || + type === "session.execution.interrupted"; + + /** A `subagent` call's item, made when the model starts it. */ + const startCall = Effect.fnUntraced(function* ( + state: ThreadState, + turn: ActiveTurn, + toolId: string, + ) { + const nativeId = `${turn.scope}${toolId}`; + const call: SubagentCall = { + toolId, + nativeId, + state, + turn, + ordinal: ordinalOf(turn, nativeId), + startedAt: yield* DateTime.now, + prompt: "", + title: null, + agent: undefined, + model: null, + background: false, + child: undefined, + status: "running", + result: null, + completedAt: null, + }; + turn.usedSubagents = true; + state.calls.set(toolId, call); + yield* emitSubagent(call); + }); + + /** + * One event for a session's running turn: its text, tools, steps and end. + * A `subagent` call is its item and child thread rather than a tool. + */ + const onTurnEvent = Effect.fnUntraced(function* ( + state: ThreadState, + turn: ActiveTurn, + event: OpenCode2StreamEvent, + ) { switch (event.type) { case "session.text.started": case "session.reasoning.started": @@ -1199,15 +1796,40 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr case "session.tool.input.started": // Its form is the item the user answers; the tool call would repeat it. if (event.data.name === "question") return; + if (event.data.name === "subagent") return yield* startCall(state, turn, event.data.id); turn.tools.set(event.data.id, { name: event.data.name, input: {} }); turn.startedAt.set(event.data.id, yield* DateTime.now); return yield* emitTool(state, turn, event.data.id, "running"); case "session.tool.called": { + const call = state.calls.get(event.data.id); + if (call !== undefined) { + const { input } = event.data; + call.prompt = stringField(input, "prompt") ?? call.prompt; + call.title = stringField(input, "description") ?? call.title; + call.agent = stringField(input, "agent"); + call.model = stringField(input, "model") ?? call.model; + call.background = input["background"] === true; + return yield* emitSubagent(call); + } const tool = turn.tools.get(event.data.id); if (tool !== undefined) tool.input = event.data.input; return yield* emitTool(state, turn, event.data.id, "running"); } + case "session.tool.progress": { + const call = state.calls.get(event.data.id); + const childId = stringField(event.data.metadata, "sessionID"); + if (call !== undefined && childId !== undefined) yield* attachChild(call, childId); + return; + } case "session.tool.success": { + const call = state.calls.get(event.data.id); + if (call !== undefined) { + const childId = stringField(event.data.metadata, "sessionID"); + if (childId !== undefined) yield* attachChild(call, childId); + // A background call returns at launch; its report settles it. + if (event.data.metadata?.["status"] === "running") return; + return yield* settleCall(call, "completed", subagentOutput(textOf(event.data.content))); + } const output = textOf(event.data.content); yield* emitTool(state, turn, event.data.id, "completed", { output, @@ -1218,6 +1840,15 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } case "session.tool.failed": { const aborted = event.data.error.type === "aborted"; + const call = state.calls.get(event.data.id); + // A refused call (unknown agent, the nesting limit) is a failed subagent. + if (call !== undefined) { + return yield* settleCall( + call, + aborted ? "interrupted" : "failed", + event.data.error.message, + ); + } yield* emitTool(state, turn, event.data.id, aborted ? "interrupted" : "failed", { output: event.data.error.message, metadata: event.data.metadata, @@ -1258,6 +1889,265 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } }); + /** + * An execution OpenCode started on a thread's session with no turn of T3's + * running: the parent's answer to a background subagent's report. It is + * held for the continuation turn it asks for, or stopped when it only + * reports subagents a Stop ended. + */ + /** + * Takes the reports an execution OpenCode started on its own delivers. When + * they all report subagents a Stop ended, that execution only answers the + * Stop, so it is stopped and `stopped` is true. + */ + const takeReports = Effect.fnUntraced(function* (state: ThreadState) { + const delivered = [...state.reports.values()]; + state.reports.clear(); + const stopped = + delivered.length > 0 && + delivered.every((entry) => state.stoppedChildren.has(entry.childId)); + for (const entry of delivered) state.stoppedChildren.delete(entry.childId); + if (stopped) { + state.unsettled = true; + yield* client.session + .interrupt({ sessionID: Session.ID.make(state.sessionId) }) + .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); + } + return { delivered, stopped }; + }); + + const onWake = Effect.fnUntraced(function* (state: ThreadState) { + const { delivered, stopped } = yield* takeReports(state); + if (stopped) return; + const wake: Wake = { + events: [], + running: true, + reports: delivered.map((entry) => entry.report), + detail: delivered.length === 0 ? null : delivered.map((entry) => entry.text).join("\n\n"), + dropped: false, + }; + state.wakes.push(wake); + yield* Effect.logInfo("OpenCode started a turn on its own; asking for a continuation.", { + providerThreadId: state.providerThread.id, + }); + yield* offerWake(state, wake); + }); + + /** A background subagent's end, as OpenCode queues it for its parent. */ + const onReport = Effect.fnUntraced(function* ( + state: ThreadState, + inboxId: string, + payload: { + readonly text: string; + readonly metadata?: Readonly> | undefined; + }, + ) { + const childId = stringField(payload.metadata, "childID"); + if (stringField(payload.metadata, "source") !== "subagent" || childId === undefined) return; + const call = [...state.calls.values()].find( + (candidate) => candidate.child?.sessionId === childId, + ); + const outcome = reportOutcome(stringField(payload.metadata, "state")); + state.reports.set(inboxId, { + childId, + text: payload.text, + report: { + kind: "subagent", + label: call?.title ?? stringField(payload.metadata, "description"), + childThreadId: call?.child?.subagent?.appThread.id, + outcome, + }, + }); + if (call === undefined) return; + yield* settleCall( + call, + state.stoppedChildren.has(childId) + ? "interrupted" + : outcome === "failed" + ? "failed" + : outcome === "cancelled" + ? "cancelled" + : "completed", + subagentOutput(payload.text), + ); + }); + + /** + * The thread whose held execution an event belongs to: the thread's own + * session, or a subagent session its held execution started. + */ + const holderOf = (sessionId: string) => { + const own = threads.get(sessionId); + if (own !== undefined) return own.wakes.at(-1)?.running === true ? own : undefined; + // A subagent a held execution started is named only when that execution replays. + const parent = announced.get(sessionId)?.parentID; + const holder = parent === undefined ? undefined : threads.get(parent); + return holder !== undefined && holder.active === undefined && holder.wakes.length > 0 + ? holder + : undefined; + }; + + const handleEvent = Effect.fnUntraced(function* (event: OpenCode2StreamEvent) { + const sessionId = sessionOfEvent(event); + if (sessionId !== undefined) { + if ( + event.type === "session.execution.started" || + event.type === "unreadable.execution.started" + ) { + busy.add(sessionId); + } else if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) { + busy.delete(sessionId); + } + } + // A subagent's session: named on its parent's `subagent` call just after. + if (event.type === "session.created" && event.data.parentID !== undefined) { + const owner = ownerOf(event.data.parentID); + if (owner !== undefined) { + childOwners.set(event.data.sessionID, owner); + announced.set(event.data.sessionID, event.data); + } + return; + } + // What a held execution does, and what the subagents it starts do, + // waits for the turn that takes it. + const holder = sessionId === undefined ? undefined : holderOf(sessionId); + const held = holder?.wakes.at(-1); + if (holder !== undefined && held !== undefined) { + held.events.push(event); + if ( + sessionId === holder.sessionId && + (event.type === "unreadable.execution.ended" || executionEnd(event.type)) + ) { + held.running = false; + } + return; + } + return yield* route(event, sessionId); + }); + + /** Handles one event for its session, as the turn running there sees it. */ + const route = Effect.fnUntraced(function* ( + event: OpenCode2StreamEvent, + sessionId: string | undefined, + ) { + // The end of the run a timed-out Stop left behind; no turn is its own. + const ended = + event.type === "unreadable.execution.ended" || executionEnd(event.type) + ? threads.get(sessionId ?? "") + : undefined; + if (ended?.unsettled === true) { + ended.unsettled = false; + return; + } + // Marks where a running turn's own execution begins; it never ends one. + // With no turn running it is a subagent's or a follow-up's start, below. + if (event.type === "unreadable.execution.started") { + const turn = threads.get(event.sessionID)?.active; + if (turn !== undefined) { + turn.awaitingStart = false; + return; + } + } + if (event.type === "unreadable.execution.ended") { + const state = threads.get(event.sessionID); + if (state === undefined || state.active?.awaitingStart === true) return; + return yield* finishTurn( + state, + event.executionType === "session.execution.succeeded" + ? { status: state.active?.interrupted === true ? "interrupted" : "completed" } + : event.executionType === "session.execution.interrupted" + ? { status: "interrupted" } + : { + status: "failed", + failure: makeProviderFailure({ + message: "OpenCode ended the turn with an error this version cannot read.", + class: "provider_error", + }), + }, + ); + } + if (event.type === "permission.asked" || event.type === "form.created") { + const asking = + event.type === "permission.asked" ? event.data.sessionID : event.data.form.sessionID; + const state = ownerOf(asking); + if (state === undefined) return; + const target = requestTurn(asking); + if (target !== undefined && !target.turn.awaitingStart) { + if (event.type === "permission.asked") return yield* onPermissionAsked(event); + return yield* onFormCreated(event); + } + // Asked by the run a Stop left behind, which nothing answers. + if (state.unsettled || state.active?.awaitingStart === true) { + yield* stopStaleRequest( + asking, + event.type === "permission.asked" + ? { type: "permission", id: event.data.id } + : { type: "form", id: event.data.form.id }, + ); + } + return; + } + // Answered in another OpenCode client, or dropped by OpenCode: a reject + // it sends on its own (a Stop, or another reject in the same session) + // cancels the request. T3's own answers are settled where they are sent. + if ( + event.type === "permission.replied" || + event.type === "form.replied" || + event.type === "form.cancelled" + ) { + const nativeId = event.type === "permission.replied" ? event.data.requestID : event.data.id; + const entry = [...pending.values()].find((candidate) => candidate.native.id === nativeId); + if (entry === undefined || entry.answering) return; + const answered = + event.type === "form.replied" || + (event.type === "permission.replied" && event.data.reply !== "reject"); + return yield* settleRequest(entry, answered ? "resolved" : "cancelled"); + } + if (sessionId === undefined) return; + const state = threads.get(sessionId); + if (state === undefined) return; + if (event.type === "session.inbox.enqueued" && event.data.item.type === "synthetic") { + return yield* onReport(state, event.data.inboxID, event.data.item.payload); + } + // Cancelled, or delivered into a turn already running: no wake reports it. + if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") { + state.reports.delete(event.data.inboxID); + return; + } + // A subagent's later prompts (a resumed subagent) open its next turn's message. + if ( + event.type === "session.inbox.enqueued" && + event.data.item.type === "user" && + state.subagent !== undefined && + state.subagent.prompt === undefined && + state.active === undefined + ) { + state.subagent.prompt = event.data.item.payload.text; + return; + } + const started = + event.type === "session.execution.started" || event.type === "unreadable.execution.started"; + // Each execution of a subagent's session is a turn on its child thread, + // unless it only answers the reports of nested subagents a Stop ended. + if (state.subagent !== undefined && state.active === undefined && started) { + if ((yield* takeReports(state)).stopped) return; + return yield* startChildTurn(state); + } + const turn = state.active; + if (turn === undefined) { + // OpenCode started the thread's session on its own. + if (started && state.subagent === undefined) return yield* onWake(state); + return; + } + // A session runs one execution at a time, and each opens with `started` + // on this ordered stream, so what comes before it is the stopped run's. + if (turn.awaitingStart) { + if (event.type === "session.execution.started") turn.awaitingStart = false; + return; + } + return yield* onTurnEvent(state, turn, event); + }); + // The stream is the only terminal signal, so a lost stream settles every // running turn and breaks the session: T3 reopens it for the next turn. // Set before the turns are settled, so a turn starting meanwhile sees it. @@ -1268,13 +2158,19 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const failure = makeProviderFailure({ message, class: "transport_error" }); yield* finishTurn(state, { status: "failed", failure }, "broken"); } + // Nothing can report a background subagent's end any more. + for (const state of threads.values()) { + for (const call of runningCalls(state)) yield* settleCall(call, "failed"); + for (const wake of state.wakes.splice(0)) wake.dropped = true; + state.reports.clear(); + } yield* setSessionStatus("error", message); yield* Queue.end(events); }); // Subscribed before any session or prompt call, so no event of theirs is missed. const stream = yield* connection.events; yield* stream.pipe( - Stream.runForEach(handleEvent), + Stream.runForEach((event) => lock.withPermit(handleEvent(event))), Effect.matchCauseEffect({ onSuccess: () => failAll("The OpenCode event stream ended."), onFailure: () => failAll("The OpenCode event stream failed."), @@ -1407,19 +2303,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr existing.rules = native.permissions; return existing; } - const state: ThreadState = { - sessionId: native.id, - providerThread, - providerTurns: new Map(), - active: undefined, - model: native.model, - unsettled: false, - directory, - agent: native.agent ?? "build", - rules: native.permissions, - policy: input.runtimePolicy, - grants: [], - }; + const state = newThreadState(native.id, providerThread, directory, undefined); + state.model = native.model; + state.agent = native.agent ?? state.agent; + state.rules = native.permissions; threads.set(native.id, state); return state; }; @@ -1437,6 +2324,144 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return `${text}\n\n${instructions}`; }; + /** Installs a turn T3 started; every path after it ends the turn with a terminal. */ + const beginTurn = ( + state: ThreadState, + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + awaitingStart: boolean, + ) => + Effect.gen(function* () { + const startedAt = yield* DateTime.now; + const nativeTurnId = `${state.sessionId}:attempt:${turnInput.attemptId}`; + const providerTurn: OrchestrationV2ProviderTurn = { + id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), + providerThreadId: turnInput.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: ref(nativeTurnId, "weak"), + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }; + const turn = makeTurn(turnInput, providerTurn, { scope: "", awaitingStart }); + // No stream is left to end this turn, so it must not start. + if (streamFailure !== undefined) { + return yield* new ProviderAdapter.ProviderAdapterEventStreamError({ + driver, + providerSessionId: input.providerSessionId, + cause: streamFailure, + }); + } + state.active = turn; + yield* emitProviderTurn(state, turn, providerTurn); + state.providerThread = { + ...state.providerThread, + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, + lastRunOrdinal: turnInput.runOrdinal, + updatedAt: startedAt, + }; + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: state.providerThread, + }); + yield* setSessionStatus("running", null); + return turn; + }); + + /** Feeds a held execution's events to the turn now running on its session. */ + const replay = Effect.fnUntraced(function* (wake: Wake) { + wake.dropped = true; + for (const event of wake.events) yield* route(event, sessionOfEvent(event)); + }); + + /** + * A user turn that starts while OpenCode runs an execution on its own: the + * prompt joins that execution, so the turn takes it, and its continuation + * turn is no longer needed. Executions that already ended stay for theirs. + */ + const takeRunningWake = Effect.fnUntraced(function* (state: ThreadState) { + const wake = state.wakes.at(-1); + if (wake?.running !== true) return; + state.wakes.pop(); + yield* replay(wake); + }); + + /** + * The continuation turn for an execution OpenCode started on its own: it + * takes the oldest one held and ends with it. One already taken (a user + * turn joined it) leaves nothing to run, so the turn ends at once. + */ + const runWake = Effect.fnUntraced(function* ( + state: ThreadState, + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + ) { + yield* beginTurn(state, turnInput, false); + yield* lock.withPermit( + Effect.gen(function* () { + const wake = state.wakes.shift(); + if (wake === undefined) return yield* finishTurn(state, { status: "completed" }); + yield* replay(wake); + }), + ); + }); + + /** + * Stops a thread's background subagents and any execution OpenCode is + * running on its own. Their sessions end as interrupted; the reports + * OpenCode then queues wake the parent into an execution that is stopped + * as well, so no turn takes it. + */ + const stopBackground = Effect.fnUntraced(function* (state: ThreadState) { + const calls = runningCalls(state).filter((call) => call.background); + // Every session under the thread that a stopped subagent reports to. + const callers = [state, ...runningCalls(state).flatMap((call) => call.child ?? [])]; + // OpenCode announces a child's session before the call's progress names + // it, so a call without a child yet is stopped through its caller's + // announced children. + const announcedTo = (caller: ThreadState) => + [...announced.values()].flatMap((info) => + info.parentID === caller.sessionId ? [info.sessionID] : [], + ); + // Each child reports to the session that called it, so its marker goes + // on that caller's state: the thread's own, or a subagent's for a nested one. + const children = new Map( + calls.flatMap((call) => + (call.child === undefined ? announcedTo(call.state) : [call.child.sessionId]).map( + (childId) => [childId, call.state] as const, + ), + ), + ); + for (const [childId, caller] of children) { + caller.stoppedChildren.add(childId); + yield* client.session + .interrupt({ sessionID: Session.ID.make(childId) }) + .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); + } + yield* lock.withPermit( + Effect.gen(function* () { + for (const call of calls) yield* settleCall(call, "interrupted"); + // A report already queued starts a follow-up the Stop must end too. + for (const caller of callers) { + for (const report of caller.reports.values()) { + caller.stoppedChildren.add(report.childId); + } + } + // A held wake no turn will take: its execution is stopped, not replayed. + const running = state.wakes.some((wake) => wake.running); + for (const wake of state.wakes.splice(0)) wake.dropped = true; + if (running) { + state.unsettled = true; + yield* client.session + .interrupt({ sessionID: Session.ID.make(state.sessionId) }) + .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); + } + }), + ); + }); + const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId, driver, @@ -1445,6 +2470,20 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return session; }, events: Stream.fromQueue(events), + // A background subagent keeps its session busy after its parent's turn, + // and a held wake still needs its turn: idle release must wait for both. + hasPendingBackgroundWork: Effect.sync(() => + [...threads.values()].some( + (state) => + hasBackground(state) || (state.subagent !== undefined && busy.has(state.sessionId)), + ), + ), + hasPendingBackgroundWorkForThread: (providerThread) => + Effect.sync(() => { + const nativeId = providerThread.nativeThreadRef?.nativeId; + const state = nativeId == null ? undefined : threads.get(nativeId); + return state !== undefined && hasBackground(state); + }), getModelContextWindow: (selection) => selection.instanceId === instanceId ? contextWindows.get(selection.model) : undefined, ensureThread: (threadInput) => @@ -1568,6 +2607,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr detail: `OpenCode session ${sessionId} already has an active turn`, }); } + // OpenCode already ran this turn on its own; it prompts nothing. + if (isContinuation(turnInput)) return yield* runWake(state, turnInput); // After a timed-out Stop the server says whether that run is gone. A // run still going is stopped again and this turn fails so it can be // sent again; a run that is gone may still have its end on the @@ -1588,59 +2629,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } } // Installs the turn; every path after it ends the turn with a terminal. - const begin = Effect.gen(function* () { - const startedAt = yield* DateTime.now; - const nativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`; - const providerTurn: OrchestrationV2ProviderTurn = { - id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), - providerThreadId: turnInput.providerThread.id, - nodeId: turnInput.rootNodeId, - runAttemptId: turnInput.attemptId, - nativeTurnRef: ref(nativeTurnId, "weak"), - ordinal: turnInput.providerTurnOrdinal, - status: "running", - startedAt, - completedAt: null, - }; - const turn: ActiveTurn = { - input: turnInput, - providerTurn, - texts: new Map(), - tools: new Map(), - startedAt: new Map(), - ordinals: new Map(), - nextOrdinal: turnInput.providerTurnOrdinal * 100 + 1, - usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0 }, - steps: 0, - lastStep: undefined, - interrupted: false, - awaitingStart: afterUnsettled, - }; - // No stream is left to end this turn, so it must not start. - if (streamFailure !== undefined) { - return yield* new ProviderAdapter.ProviderAdapterEventStreamError({ - driver, - providerSessionId: input.providerSessionId, - cause: streamFailure, - }); - } - state.active = turn; - yield* emitProviderTurn(state, turn, providerTurn); - state.providerThread = { - ...state.providerThread, - status: "active", - firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, - lastRunOrdinal: turnInput.runOrdinal, - updatedAt: startedAt, - }; - yield* emit({ - type: "provider_thread.updated", - driver, - providerThread: state.providerThread, - }); - yield* setSessionStatus("running", null); - return turn; - }); + const begin = beginTurn(state, turnInput, afterUnsettled); if (stillStopping) { yield* begin; return yield* finishTurn(state, { @@ -1673,6 +2662,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr state.model = model; } const turn = yield* begin; + // An execution OpenCode is running on its own takes this prompt at + // its next step, so this turn is that execution from here on. + yield* lock.withPermit(takeRunningWake(state)); yield* client.session .prompt({ sessionID: Session.ID.make(sessionId), text: prompt(turnInput) }) .pipe( @@ -1731,12 +2723,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr Effect.gen(function* () { const sessionId = yield* sessionIdOf(interruptInput.providerThread); const state = threads.get(sessionId); - const turn = state?.active; - if ( - state === undefined || - turn === undefined || - turn.providerTurn.id !== interruptInput.providerTurnId - ) { + if (state === undefined) return; + const turn = state.active; + // OpenCode stops a foreground subagent with its parent, but not a + // background one: a user Stop (`requestRuntimeRestart`) stops those + // too, and the execution OpenCode starts to report them. A turn + // interrupted to restart it with new input leaves them running. + if (interruptInput.requestRuntimeRestart === true) yield* stopBackground(state); + if (turn === undefined || turn.providerTurn.id !== interruptInput.providerTurnId) { return; } // The session answers with `session.execution.interrupted`, which ends @@ -1756,7 +2750,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); if (reply._tag === "None") { state.unsettled = true; - return yield* finishTurn(state, { status: "interrupted" }); + return yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); } // Nothing was running. Unless the execution already ended (its event // is on the way), the turn is still open and nothing stopped. @@ -1783,10 +2777,19 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr Effect.sync(() => { const nativeId = providerThread.nativeThreadRef?.nativeId; const state = nativeId == null ? undefined : threads.get(nativeId); - if (nativeId == null || state === undefined || state.active !== undefined) return; + if ( + nativeId == null || + state === undefined || + state.active !== undefined || + hasBackground(state) + ) { + return; + } threads.delete(nativeId); for (const [child, owner] of childOwners) { - if (owner === state) childOwners.delete(child); + if (owner !== state) continue; + childOwners.delete(child); + threads.delete(child); } }), respondToRuntimeRequest: (requestInput) => @@ -1810,11 +2813,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // OpenCode's own "always" saves a grant for the whole project, so a // session-wide answer is a rule on this session instead. The grant // is best effort: this request is answered either way. + // The session that asked waits on the answer and holds the grant: a + // subagent's own, not its parent's. + const asker = threads.get(entry.sessionId) ?? entry.state; if ( native.type === "permission" && (decision === "acceptForSession" || decision === "acceptAlways") ) { - const { state } = entry; + const state = asker; for (const resource of native.save) { if ( !state.grants.some( @@ -1824,7 +2830,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr state.grants.push({ action: native.action, resource, effect: "allow" }); } } - yield* writeRules(state, state.policy).pipe(Effect.ignore({ log: true })); + // A subagent runs under its thread's mode. + yield* writeRules(state, rootOf(state).policy).pipe(Effect.ignore({ log: true })); } const sessionID = Session.ID.make(entry.sessionId); // Best effort: the decline stands without the note. @@ -1864,7 +2871,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ).pipe(Effect.catchTags(formGone)), ); yield* forgetRequest(entry); - if (!delivered) yield* abandonRequest(entry.state, "answer not delivered"); + if (!delivered) yield* abandonRequest(asker, "answer not delivered"); }).pipe( Effect.mapError((cause) => isProviderAdapterError(cause) diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 66390f857e7c..569e92dd2be4 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -48,10 +48,7 @@ const event = (type: string, data: Record): ProviderReplayEntry event: { id: `evt_${type.replaceAll(".", "")}`, created: 1, type, data }, }, }); -const T3_RULES = [ - { action: "*", resource: "*", effect: "allow" }, - { action: "subagent", resource: "*", effect: "deny" }, -]; +const T3_RULES = [{ action: "*", resource: "*", effect: "allow" }]; /** Paths the build and plan agents allow for themselves, as 2.0.18 lists them. */ const BUILD_PATHS = [ { @@ -104,21 +101,18 @@ const SUPERVISED_RULES = [ { action: "edit", resource: "*", effect: "ask" }, { action: "external_directory", resource: "*", effect: "ask" }, ...BUILD_PATHS, - { action: "subagent", resource: "*", effect: "deny" }, ]; const AUTO_EDIT_RULES = [ { action: "shell", resource: "*", effect: "ask" }, { action: "edit", resource: "*", effect: "allow" }, { action: "external_directory", resource: "*", effect: "ask" }, ...BUILD_PATHS, - { action: "subagent", resource: "*", effect: "deny" }, ]; /** Plan mode on Full access: edits are denied except the plan agent's own plan files. */ const PLAN_RULES = [ { action: "*", resource: "*", effect: "allow" }, { action: "edit", resource: "*", effect: "deny" }, ...PLAN_PATHS, - { action: "subagent", resource: "*", effect: "deny" }, ]; const sessionInfo = (directory: string, permissions: ReadonlyArray = T3_RULES) => ({ @@ -372,11 +366,15 @@ describe("OpenCode 2 through the orchestrator", () => { ...createdSession(before), ...answeredPrompt("FIRST"), // Reopened after a worktree change, the session reports the rules an - // older build gave it; they are replaced before anything runs. + // older build gave it, which denied subagents; they are replaced + // before anything runs. out("session.get", { sessionID: SESSION }), reply( "session.get", - sessionInfo(before, [{ action: "*", resource: "*", effect: "allow" }]), + sessionInfo(before, [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, + ]), ), ...noOpenRequests, out("session.update", { sessionID: SESSION, permissions: T3_RULES }), diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 87a4dff5ac0b..fd09da7b069d 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -58,6 +58,10 @@ import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import { worktreeRepairDependenciesTestLayer } from "./ProviderTurnStartService.testkit.ts"; import { OrchestrationV2LayerLive } from "./runtimeLayer.ts"; +import * as IdAllocator from "./IdAllocator.ts"; +import * as ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; +import * as ProviderContinuationService from "./ProviderContinuationService.ts"; +import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; const binaryPath = process.env.OPENCODE2_BIN; @@ -140,7 +144,7 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe ), ), ); -const liveLayer = OrchestrationV2LayerLive.pipe( +const orchestrationLayer = OrchestrationV2LayerLive.pipe( Layer.provide(worktreeRepairDependenciesTestLayer), Layer.provide(McpSessionRegistryTestkit.layer), Layer.provide(SqlitePersistenceMemory), @@ -154,6 +158,25 @@ const liveLayer = OrchestrationV2LayerLive.pipe( Layer.provide(PlatformTestLayer), ); +// Starts the continuation run a provider wake asks for, as the production layer does. +const continuationWorkerLayer = ProviderContinuationService.workerLive.pipe( + Layer.provide( + Layer.unwrap( + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + return Layer.mock(ThreadManagementService.ThreadManagementService)({ + dispatch: orchestrator.dispatch, + getThreadRecords: orchestrator.getThreadRecords, + getThreadProjection: orchestrator.getThreadProjection, + }); + }), + ), + ), + Layer.provide(IdAllocator.layer), + Layer.provide(ProviderContinuationRequests.layer), +); +const liveLayer = continuationWorkerLayer.pipe(Layer.provideMerge(orchestrationLayer)); + const settled = (projection: OrchestrationV2ThreadProjection) => projection.runs.length > 0 && projection.runs.every( @@ -480,4 +503,128 @@ describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchest }).pipe(Effect.provide(Layer.merge(liveLayer, NodeServices.layer)), Effect.scoped), 360_000, ); + + it.live( + "runs subagents, wakes the thread for a background one, and stops one still running", + () => + Effect.gen(function* () { + yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); + const orchestrator = yield* Orchestrator.OrchestratorV2; + // One thread per case: a model that just answered one prompt may repeat it. + const thread = Effect.fn("OpenCode2Live.subagentThread")(function* (key: string) { + const threadId = ThreadId.make(`thread:opencode2-live-subagents-${key}`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:opencode2-live:subagents-${key}-create`), + threadId, + projectId: ProjectId.make("project:opencode2-live"), + title: `OpenCode 2 live subagents ${key}`, + modelSelection: MODEL, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: `${ROOT}/work`, + }); + return threadId; + }); + const childOf = (projection: OrchestrationV2ThreadProjection) => + Effect.gen(function* () { + const childThreadId = projection.subagents[0]?.childThreadId; + assert.isDefined(childThreadId ?? undefined); + return yield* orchestrator.getThreadProjection(childThreadId!); + }); + + // Foreground: the turn waits for the child thread's answer. + const fgThread = yield* thread("fg"); + yield* send( + fgThread, + "subagents-fg", + "Use the subagent tool (foreground, not background) to delegate to the general subagent with the prompt: 'Reply exactly FG_CHILD_OK.' Wait for it, then reply exactly FG_PARENT_OK.", + ); + const foreground = yield* waitFor(fgThread, settled); + assert.deepEqual( + foreground.runs.map((run) => run.status), + ["completed"], + ); + assert.equal(foreground.subagents[0]?.status, "completed"); + const fgChild = yield* childOf(foreground); + assert.isTrue( + fgChild.turnItems.some( + (item) => item.type === "assistant_message" && item.text.includes("FG_CHILD_OK"), + ), + ); + + // Background: run 1 ends first, then OpenCode wakes the parent when the + // child ends, and T3 opens run 2 for that execution. + const bgThread = yield* thread("bg"); + yield* send( + bgThread, + "subagents-bg", + "Use the subagent tool with background set to true to delegate to the general subagent with the prompt: 'Run the shell command `sleep 8` with the shell tool, then reply exactly BG_CHILD_OK.' As soon as it is launched, reply exactly BG_LAUNCHED and end your turn without waiting.", + ); + const woke = yield* waitFor( + bgThread, + (projection) => projection.runs.length === 2 && settled(projection), + ); + assert.deepEqual( + woke.runs.map((run) => run.status), + ["completed", "completed"], + ); + assert.equal(woke.subagents[0]?.status, "completed"); + const wakeMessage = woke.messages.find( + (message) => message.id === woke.runs[1]?.userMessageId, + ); + assert.equal(`${wakeMessage?.createdBy}:${wakeMessage?.creationSource}`, "agent:provider"); + assert.isTrue( + woke.turnItems.some( + (item) => item.runId === woke.runs[1]?.id && item.type === "assistant_message", + ), + "the parent's answer to the report lands in the continuation run", + ); + + // Stop while a background child runs: OpenCode keeps a background + // child running past a parent Stop, so T3 stops it, and nothing wakes. + const stopThread = yield* thread("stop"); + yield* send( + stopThread, + "subagents-stop", + "Use the subagent tool with background set to true to delegate to the general subagent with the prompt: 'Run the shell command `sleep 60` with the shell tool, then reply exactly LATE.' As soon as it is launched, reply exactly STOP_LAUNCHED and end your turn without waiting.", + ); + const launched = yield* waitFor( + stopThread, + (projection) => + projection.runs[0]?.status !== "running" && + projection.subagents[0]?.status === "running", + ); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("command:opencode2-live:subagents-stop-interrupt"), + threadId: stopThread, + runId: launched.runs[0]!.id, + }); + yield* waitFor( + stopThread, + (projection) => + settled(projection) && + projection.subagents[0] !== undefined && + projection.subagents[0].status !== "running", + ); + // Still settled once OpenCode has reported the stopped child to its parent. + yield* Effect.sleep("5 seconds"); + const final = yield* orchestrator.getThreadProjection(stopThread); + assert.lengthOf(final.runs, 1); + assert.equal(final.subagents[0]?.status, "interrupted"); + assert.isFalse( + final.turnItems.some((item) => item.status === "running" || item.status === "waiting"), + ); + const stoppedChild = yield* childOf(final); + assert.isFalse( + stoppedChild.turnItems.some((item) => item.status === "running"), + "the stopped child shows nothing running", + ); + }).pipe(Effect.provide(Layer.merge(liveLayer, NodeServices.layer)), Effect.scoped), + 480_000, + ); }); diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts index e6f708d88db6..b60a5b553cbb 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts @@ -1,6 +1,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import type { OrchestrationV2DomainEvent, ProviderReplayTranscript } from "@t3tools/contracts"; +import type { + OrchestrationV2DomainEvent, + ProviderReplayEntry, + ProviderReplayTranscript, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -85,8 +89,10 @@ const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* < readonly buildInput: () => OrchestratorFixtureInput; readonly driver: ProviderOrchestratorReplayVariant; readonly harness: OrchestratorV2ProviderReplayHarness; + readonly transformTranscript?: (transcript: ProviderReplayTranscript) => ProviderReplayTranscript; }) { - const rawTranscript = yield* readTranscript(input.driver.transcriptFile); + const recordedTranscript = yield* readTranscript(input.driver.transcriptFile); + const rawTranscript = input.transformTranscript?.(recordedTranscript) ?? recordedTranscript; const replayTranscript = materializeReplayTranscriptRuntimeInstructions( transcriptEntriesThroughLabel(rawTranscript, input.driver.transcriptEntriesThroughLabel), { driver: input.driver.driver, model: input.driver.modelSelection.model }, @@ -163,6 +169,7 @@ function runFixtureProviderWithRegisteredHarness(input: { readonly fixtureName: string; readonly buildInput: () => OrchestratorFixtureInput; readonly driver: ProviderOrchestratorReplayVariant; + readonly transformTranscript?: (transcript: ProviderReplayTranscript) => ProviderReplayTranscript; }) { switch (input.driver.driver) { case "codex": @@ -243,4 +250,42 @@ describe("orchestrator replay fixtures", () => { }), ); } + + // A later OpenCode may change an execution start's shape; the client then + // reads it as `unreadable.execution.started`. A subagent's turn and a + // background follow-up must still start from it. + for (const [fixtureName, label] of [ + ["opencode2_subagent", "session.execution.started.2"], + ["opencode2_background", "session.execution.started.3"], + ] as const) { + const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find( + (candidate) => candidate.name === fixtureName, + ); + const provider = fixture?.providers[0]; + if (fixture === undefined || provider === undefined) continue; + it.effect( + `runs ${fixtureName} when ${label} is an execution start this build cannot decode`, + () => + runFixtureProviderWithRegisteredHarness({ + fixtureName, + buildInput: fixture.buildInput, + driver: provider, + transformTranscript: (transcript) => ({ + ...transcript, + entries: transcript.entries.map((entry) => + entry.type === "emit_inbound" && entry.label === label + ? undecodableEvent(entry) + : entry, + ), + }), + }), + ); + } }); + +/** The same event with an envelope this build cannot decode, as a newer OpenCode may send. */ +function undecodableEvent(entry: ProviderReplayEntry): ProviderReplayEntry { + if (entry.type !== "emit_inbound") return entry; + const frame = entry.frame as { readonly event: Record }; + return { ...entry, frame: { ...frame, event: { ...frame.event, durable: "not-an-envelope" } } }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index eda11b94d651..9936dc827176 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -61,6 +61,10 @@ import { openCode2PermissionInput } from "./opencode2_permission/input.ts"; import { assertOpenCode2PermissionOutput } from "./opencode2_permission/output.ts"; import { openCode2QuestionInput } from "./opencode2_question/input.ts"; import { assertOpenCode2QuestionOutput } from "./opencode2_question/output.ts"; +import { openCode2BackgroundInput } from "./opencode2_background/input.ts"; +import { assertOpenCode2BackgroundOutput } from "./opencode2_background/output.ts"; +import { openCode2SubagentInput } from "./opencode2_subagent/input.ts"; +import { assertOpenCode2SubagentOutput } from "./opencode2_subagent/output.ts"; import { openCode2SimpleInput } from "./opencode2_simple/input.ts"; import { assertOpenCode2SimpleOutput } from "./opencode2_simple/output.ts"; import { openCode2ToolCallInput } from "./opencode2_tool_call/input.ts"; @@ -853,6 +857,40 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray"}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} +{"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3259001f54idsWUsyJ1uY", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657049201, "updated": 1790657049201}, "title": "spike background", "location": {"directory": ""}}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3271001Z06XG1eDh56UaK", "created": 1790657049201, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "slug": "cosmic-harbor", "version": "2.0.18", "projectID": "global", "location": {"directory": ""}, "subpath": "", "title": "spike background", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7a327f001vnY9Kg4imbAi0P", "sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "time": {"created": 1790657049216}, "type": "user", "payload": {"text": "Use the subagent tool with background enabled to delegate to the general subagent with the prompt: 'Run the shell command `sleep 20` with the bash tool and then reply exactly CHILD_OK.' As soon as it is launched, reply exactly PARENT_OK and end your turn without waiting for it."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3280001qlssYW5zgVI8Tw", "created": 1790657049216, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7a327f001vnY9Kg4imbAi0P", "sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "item": {"type": "user", "payload": {"text": "Use the subagent tool with background enabled to delegate to the general subagent with the prompt: 'Run the shell command `sleep 20` with the bash tool and then reply exactly CHILD_OK.' As soon as it is launched, reply exactly PARENT_OK and end your turn without waiting for it."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3284001FqmD8z0P7z3k2p", "created": 1790657049220, "type": "session.execution.started", "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb"}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a328f001YZyiZPb0Hm8e9k", "created": 1790657049231, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "delta": {"core/environment": "1bb3582c7cc54f6d6712f786476c444688a0eda856bda11a5f70b2a9c9a3aa73", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a32930010i5lxqQVmno2d8", "created": 1790657049235, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "inboxID": "msg_0eb7a327f001vnY9Kg4imbAi0P"}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a38ee001h7pK36JNK0Y5GT", "created": 1790657050862, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7a329a001v2Afth1gD0MVUK", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657049264}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a38f1001NA81UEdRalmkWn", "created": 1790657050865, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a329a001v2Afth1gD0MVUK", "id": "call-cccf2157-e8a3-456c-a74f-fccd26fb83eb", "name": "subagent"}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3aca001b17QE0GjYkHVfb", "created": 1790657051339, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a329a001v2Afth1gD0MVUK", "id": "call-cccf2157-e8a3-456c-a74f-fccd26fb83eb", "text": "{\"agent\":\"general\",\"description\":\"Sleep and reply\",\"prompt\":\"Run the shell command `sleep 20` with the bash tool and then reply exactly CHILD_OK.\",\"background\":true}"}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3ace001QGpncxxihexfmb", "created": 1790657051342, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a329a001v2Afth1gD0MVUK", "id": "call-cccf2157-e8a3-456c-a74f-fccd26fb83eb", "input": {"agent": "general", "description": "Sleep and reply", "prompt": "Run the shell command `sleep 20` with the bash tool and then reply exactly CHILD_OK.", "background": true}, "executed": false}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3ad2001dY5uSl5KY1aWnc", "created": 1790657051346, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a329a001v2Afth1gD0MVUK"}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.created.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3ae6001i5M9Qn0eRnLKow", "created": 1790657051366, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "slug": "proud-canyon", "version": "2.0.18", "projectID": "global", "parentID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "location": {"directory": ""}, "subpath": "", "title": "Sleep and reply", "agent": "general", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 0, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.progress", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3ae9001hCuTh3HuQ3wBFn", "created": 1790657051369, "type": "session.tool.progress", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a329a001v2Afth1gD0MVUK", "id": "call-cccf2157-e8a3-456c-a74f-fccd26fb83eb", "metadata": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "status": "running"}}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3aeb001mtUY1Wh4slZxP3", "created": 1790657051371, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7a3aea001Lw1Pav5d4akfEg", "sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "item": {"type": "user", "payload": {"text": "You are a subagent spawned by another session.\nRun the shell command `sleep 20` with the bash tool and then reply exactly CHILD_OK."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.success", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3aef001pQMmHqZuvLpEQZ", "created": 1790657051375, "type": "session.tool.success", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a329a001v2Afth1gD0MVUK", "id": "call-cccf2157-e8a3-456c-a74f-fccd26fb83eb", "content": [{"type": "text", "text": "The subagent is working in the background (sessionID: ses_f1485c529ffea4URrYruwEg0Ja). You will be notified automatically when it finishes.\nDO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.\nWork on non-overlapping tasks, or briefly tell the user what you launched and end your response."}], "metadata": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "status": "running", "truncated": false}, "executed": false}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 10, "version": 2}}}} +{"type": "emit_inbound", "label": "session.execution.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3af2001hO9Z4AxlZq27MR", "created": 1790657051378, "type": "session.execution.started", "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja"}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3afa001eNACDYX5q1qUS6", "created": 1790657051386, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "delta": {"core/environment": "db5cc95c66c9bac8e9bcba76f0166e1b2976dcd8491cd04e097619e4c840be02", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3afd001A8rxnop32FEuji", "created": 1790657051389, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "inboxID": "msg_0eb7a3aea001Lw1Pav5d4akfEg"}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3b18001M0Cmt7K1Tvp523", "created": 1790657051416, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a329a001v2Afth1gD0MVUK", "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 8845, "output": 76, "reasoning": 0, "cache": {"read": 384, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3b1c0012d2AxjRaJv7hFd", "created": 1790657051420, "type": "session.usage.updated", "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "cost": 0, "tokens": {"input": 8845, "output": 76, "reasoning": 0, "cache": {"read": 384, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.step.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3e1400129CnvnSzqsxq1o", "created": 1790657052180, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7a3b25001ZbqQePIK9xzVSg", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657051450}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 12, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3e18001O6lncgocXRnTbk", "created": 1790657052184, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a3b25001ZbqQePIK9xzVSg", "ordinal": 0}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 13, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3e1c001aeCuO7lGiIpZ1P", "created": 1790657052188, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a3b25001ZbqQePIK9xzVSg", "ordinal": 0, "delta": "PARENT_OK"}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3e1d001HWmW1PPRkBS46b", "created": 1790657052189, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a3b25001ZbqQePIK9xzVSg", "ordinal": 0, "text": "PARENT_OK"}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 14, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3e20001VM5DsXOiWwN1at", "created": 1790657052192, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a3b25001ZbqQePIK9xzVSg"}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 15, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3e2c001HV5tmM6XWRO87c", "created": 1790657052204, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a3b25001ZbqQePIK9xzVSg", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 196, "output": 5, "reasoning": 0, "cache": {"read": 9216, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 16, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3e30001qHW6iiTcUz398r", "created": 1790657052209, "type": "session.usage.updated", "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "cost": 0, "tokens": {"input": 9041, "output": 81, "reasoning": 0, "cache": {"read": 9600, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3e320014gaBZkE6ODWOGz", "created": 1790657052210, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb"}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 17, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a42e0001Lw82dQK2S7F4fA", "created": 1790657053408, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "agent": "general", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7a3b03001aS3qJ8L5Jb2lQA", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657051460}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a42e60015JoyiULcSTiWV3", "created": 1790657053414, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "assistantMessageID": "msg_0eb7a3b03001aS3qJ8L5Jb2lQA", "id": "call_function_s6bjpbvhrlpl_1", "name": "shell"}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a4311001RvqEeLYcJqHBU1", "created": 1790657053457, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "assistantMessageID": "msg_0eb7a3b03001aS3qJ8L5Jb2lQA", "id": "call_function_s6bjpbvhrlpl_1", "text": "{\"command\": \"sleep 20\"}"}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a4316001uZtLETYzWqwfGZ", "created": 1790657053462, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "assistantMessageID": "msg_0eb7a3b03001aS3qJ8L5Jb2lQA", "id": "call_function_s6bjpbvhrlpl_1", "input": {"command": "sleep 20"}, "executed": false}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a431a001MXzBEkd9Ds8htN", "created": 1790657053467, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "assistantMessageID": "msg_0eb7a3b03001aS3qJ8L5Jb2lQA"}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "shell.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a4327001CuQfFPBwL0KRaX", "created": 1790657053479, "type": "shell.created", "location": {"directory": ""}, "data": {"info": {"id": "sh_0eb7a4324001OUYfV2HeA220HO", "status": "running", "command": "sleep 20", "cwd": "", "shell": "/bin/bash", "file": "/xdg-data/opencode/shell/global/sh_0eb7a4324001OUYfV2HeA220HO.out", "metadata": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja"}, "time": {"started": 1790657053476}}}}}} +{"type": "emit_inbound", "label": "session.tool.progress.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a43280012PqZqjVdYFHGET", "created": 1790657053480, "type": "session.tool.progress", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "assistantMessageID": "msg_0eb7a3b03001aS3qJ8L5Jb2lQA", "id": "call_function_s6bjpbvhrlpl_1", "metadata": {"shellID": "sh_0eb7a4324001OUYfV2HeA220HO"}}}}} +{"type": "emit_inbound", "label": "session.tool.success.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a914b001Ftcn6alskksLC7", "created": 1790657073483, "type": "session.tool.success", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "assistantMessageID": "msg_0eb7a3b03001aS3qJ8L5Jb2lQA", "id": "call_function_s6bjpbvhrlpl_1", "content": [{"type": "text", "text": "(no output)"}], "metadata": {"status": "completed", "truncated": false, "exit": 0}, "executed": false}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 10, "version": 2}}}} +{"type": "emit_inbound", "label": "shell.exited", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a914f001u4kat8QHw4FgZh", "created": 1790657073487, "type": "shell.exited", "location": {"directory": ""}, "data": {"id": "sh_0eb7a4324001OUYfV2HeA220HO", "exit": 0, "status": "exited"}}}} +{"type": "emit_inbound", "label": "project.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a9154001YSH1n8Cs3VFThD", "created": 1790657073492, "type": "project.updated", "location": {"directory": ""}, "data": {"id": "global", "canonical": "", "vcs": "git", "time": {"created": 1790656063718, "updated": 1790656063718, "active": 1790657073491}, "sandboxes": []}}}} +{"type": "emit_inbound", "label": "session.step.ended.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a915a001iUN2IKFI8kzBaa", "created": 1790657073498, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "assistantMessageID": "msg_0eb7a3b03001aS3qJ8L5Jb2lQA", "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 7816, "output": 28, "reasoning": 0, "cache": {"read": 486, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a915e001c8dMcBUNts99L8", "created": 1790657073502, "type": "session.usage.updated", "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "cost": 0, "tokens": {"input": 7816, "output": 28, "reasoning": 0, "cache": {"read": 486, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.step.started.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a9678001RDBPsSDhsk5TuH", "created": 1790657074808, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "agent": "general", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7a916c001NRJQJ5gwIT0QnU", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657073547}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 12, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a967c001LoPATyK1I11ycR", "created": 1790657074812, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "assistantMessageID": "msg_0eb7a916c001NRJQJ5gwIT0QnU", "ordinal": 0}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 13, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a9680001wRLCbqlciOkaFY", "created": 1790657074816, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "assistantMessageID": "msg_0eb7a916c001NRJQJ5gwIT0QnU", "ordinal": 0, "delta": "CHILD_OK"}}}} +{"type": "emit_inbound", "label": "session.text.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a9681001QhET8NZGinfzrw", "created": 1790657074817, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "assistantMessageID": "msg_0eb7a916c001NRJQJ5gwIT0QnU", "ordinal": 0, "text": "CHILD_OK"}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 14, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a9684001BNDzzTmsDFjuUK", "created": 1790657074820, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "assistantMessageID": "msg_0eb7a916c001NRJQJ5gwIT0QnU"}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 15, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a969c001xUWW2z72vBdJzk", "created": 1790657074844, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "assistantMessageID": "msg_0eb7a916c001NRJQJ5gwIT0QnU", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 20, "output": 4, "reasoning": 0, "cache": {"read": 8328, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 16, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a969f001iArYNmwZAJBTEY", "created": 1790657074847, "type": "session.usage.updated", "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja", "cost": 0, "tokens": {"input": 7836, "output": 32, "reasoning": 0, "cache": {"read": 8814, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a96a0001sCrwwvDFSVi4Q1", "created": 1790657074848, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f1485c529ffea4URrYruwEg0Ja"}, "durable": {"aggregateID": "ses_f1485c529ffea4URrYruwEg0Ja", "seq": 17, "version": 1}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a96a50011sM6dODYviD8BJ", "created": 1790657074853, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7a3aee0015KmTB8XkMDW8iy", "sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "item": {"type": "synthetic", "payload": {"text": "\nCHILD_OK\n", "description": "Sleep and reply", "metadata": {"source": "subagent", "childID": "ses_f1485c529ffea4URrYruwEg0Ja", "agent": "General", "state": "completed"}}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 18, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a96a8001XuuGklKrRKE11l", "created": 1790657074856, "type": "session.execution.started", "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb"}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 19, "version": 1}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a96b20012H1uP49b0ylmIW", "created": 1790657074866, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "inboxID": "msg_0eb7a3aee0015KmTB8XkMDW8iy"}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 20, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a99980017YHiUt27t7ytaS", "created": 1790657075609, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7a96bb00170K1f1HJUeBNwF", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657074896}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 21, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a999c0012qV62Wzb7L2oZ9", "created": 1790657075612, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a96bb00170K1f1HJUeBNwF", "ordinal": 0}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 22, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a99a2001L0QFQbqm9d49LQ", "created": 1790657075618, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a96bb00170K1f1HJUeBNwF", "ordinal": 0, "delta": "The background subagent finished and returned `CHILD_OK`."}}}} +{"type": "emit_inbound", "label": "session.text.ended.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a99a3001LGHzpQRKYs4P4T", "created": 1790657075619, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a96bb00170K1f1HJUeBNwF", "ordinal": 0, "text": "The background subagent finished and returned `CHILD_OK`."}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 23, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a99a6001AwV3XWHR45uZmp", "created": 1790657075622, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a96bb00170K1f1HJUeBNwF"}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 24, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a99b4001GUZ2blYoA58laM", "created": 1790657075636, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "assistantMessageID": "msg_0eb7a96bb00170K1f1HJUeBNwF", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 123, "output": 14, "reasoning": 0, "cache": {"read": 9344, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 25, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a99b80018h9O8cHaxlcM6U", "created": 1790657075640, "type": "session.usage.updated", "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "cost": 0, "tokens": {"input": 9164, "output": 95, "reasoning": 0, "cache": {"read": 18944, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a99b9001sUQhsz4jC6iBiA", "created": 1790657075641, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb"}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 26, "version": 1}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background/output.ts new file mode 100644 index 000000000000..6c124641d1d5 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background/output.ts @@ -0,0 +1,114 @@ +import { assert } from "@effect/vitest"; +import type { OrchestrationV2ThreadProjection, ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertBaseProjection, + assertProviderNativeSubagentRootTurns, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + backgroundNotifications, + OPENCODE2_BACKGROUND_PROMPT, + projectionFor, +} from "../shared.ts"; + +const runTexts = (projection: OrchestrationV2ThreadProjection, runId: string | undefined) => + projection.turnItems.flatMap((item) => + item.runId === runId && item.type === "assistant_message" ? [item.text.trim()] : [], + ); + +/** + * A background `subagent` call. The parent's first execution ends at once, + * so run 1 waits on the subagent. When it ends, OpenCode reports it to the + * parent and runs the parent again on its own: that execution is run 2, a + * continuation T3 opens for it, and nothing stays waiting afterwards. + */ +export function assertOpenCode2BackgroundOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ + result, + transcript, + runCount: 2, + runStatuses: ["completed", "completed"], + }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertProviderNativeSubagentRootTurns(result); + assertUserMessagesInclude(projection, [OPENCODE2_BACKGROUND_PROMPT]); + const [rootRun, wakeRun] = projection.runs; + + assert.lengthOf(projection.subagents, 1); + const subagent = projection.subagents[0]!; + assert.deepInclude(subagent, { + origin: "provider_native", + status: "completed", + runId: rootRun!.id, + title: "Sleep and reply", + result: "CHILD_OK", + }); + assert.deepEqual(runTexts(projection, rootRun?.id), ["PARENT_OK"]); + + // Run 1's turn ends while the subagent runs: the run parks at `waiting` on + // that background work, and the wake follows the subagent's end. + const indexOf = (predicate: (event: (typeof result.domainEvents)[number]) => boolean) => + result.domainEvents.findIndex(predicate); + const rootWaiting = indexOf( + (event) => + event.type === "run.updated" && + event.payload.id === rootRun?.id && + event.payload.status === "waiting", + ); + const subagentDone = indexOf( + (event) => + event.type === "turn-item.updated" && + event.payload.type === "subagent" && + event.payload.status === "completed", + ); + const wakeStarted = indexOf( + (event) => event.type === "run.updated" && event.payload.id === wakeRun?.id, + ); + assert.isAtLeast(rootWaiting, 0); + assert.isAbove(subagentDone, rootWaiting, "run 1 ends its turn before its subagent ends"); + assert.isAbove(wakeStarted, subagentDone, "the wake follows the subagent's end"); + + // The child's own tool call and answer stay in its thread. + const child = result.projections.get(subagent.childThreadId!); + assert.isDefined(child); + assert.isTrue( + child.turnItems.some( + (item) => item.type === "command_execution" && item.input.includes("sleep 20"), + ), + ); + assert.isTrue( + child.turnItems.some((item) => item.type === "assistant_message" && item.text === "CHILD_OK"), + ); + assert.isFalse( + projection.turnItems.some((item) => item.type === "command_execution"), + "the child's shell call must not land on the parent", + ); + + // The continuation is a provider wake that names the subagent, and it + // carries the parent's own answer to the report. + const wakeMessage = projection.messages.find((message) => message.id === wakeRun?.userMessageId); + assert.equal(`${wakeMessage?.createdBy}:${wakeMessage?.creationSource}`, "agent:provider"); + assert.include(wakeMessage?.text ?? "", "CHILD_OK"); + assert.deepEqual(backgroundNotifications(projection), [ + { + summary: 'Subagent "Sleep and reply" finished', + outcome: "completed", + source: { kind: "subagent", childThreadId: subagent.childThreadId! }, + }, + ]); + assert.deepEqual(runTexts(projection, wakeRun?.id), [ + "The background subagent finished and returned `CHILD_OK`.", + ]); + + // Nothing is left waiting once the subagent and the wake ended. + const shell = result.shellSnapshot.threads.find((thread) => thread.id === projection.thread.id); + assert.deepEqual(shell?.pendingBackgroundTasks ?? [], []); + assert.isFalse( + projection.turnItems.some((item) => item.status === "running" || item.status === "waiting"), + ); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/input.ts new file mode 100644 index 000000000000..328b266c542b --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/input.ts @@ -0,0 +1,5 @@ +import { OPENCODE2_SUBAGENT_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2SubagentInput(): OrchestratorFixtureInput { + return { steps: [{ type: "message", text: OPENCODE2_SUBAGENT_PROMPT }] }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/opencode_transcript.ndjson new file mode 100644 index 000000000000..1e1ac6f30e72 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/opencode_transcript.ndjson @@ -0,0 +1,81 @@ +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_subagent", "metadata": {"source": "opencode2-spike/recordings/subagent.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary."}} +{"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} +{"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": ""}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} +{"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79d66d001akjvdikgkVMT2n", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657025663, "updated": 1790657025663}, "title": "spike subagent", "location": {"directory": ""}}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79d67f001cKJ38bhZTN6LOd", "created": 1790657025663, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "slug": "tidy-wizard", "version": "2.0.18", "projectID": "global", "location": {"directory": ""}, "subpath": "", "title": "spike subagent", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb79d68b001Au0Ty7w09VhWVs", "sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "time": {"created": 1790657025676}, "type": "user", "payload": {"text": "Use the subagent tool to delegate to the explore subagent with the prompt: 'List the files in the current directory and report their names.' Wait for it, then summarize its answer in one line."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79d68c001ctcXXGdXOjF4Ss", "created": 1790657025676, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb79d68b001Au0Ty7w09VhWVs", "sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "item": {"type": "user", "payload": {"text": "Use the subagent tool to delegate to the explore subagent with the prompt: 'List the files in the current directory and report their names.' Wait for it, then summarize its answer in one line."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79d699001URQRsPim06J5nM", "created": 1790657025689, "type": "session.execution.started", "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R"}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79d6a9001oJpy0ZsFCYCZOT", "created": 1790657025705, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "delta": {"core/environment": "6ebe0b2594ebd10453f6d910906c18a79a51a562e1574d342d45d547d05534c7", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79d6ad001psg31ose1rKT21", "created": 1790657025709, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "inboxID": "msg_0eb79d68b001Au0Ty7w09VhWVs"}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e295001xes8Usjz1RspCM", "created": 1790657028757, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb79d6b5001MbW4xoDkLv1W0a", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657025749}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e298001B5LQWQD1Rd8cUO", "created": 1790657028760, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb79d6b5001MbW4xoDkLv1W0a", "ordinal": 0}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e2ff00163WYtUExwvSNbZ", "created": 1790657028863, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb79d6b5001MbW4xoDkLv1W0a", "ordinal": 0, "delta": "I'll delegate that"}}}} +{"type": "emit_inbound", "label": "session.text.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e3ed001If6mdxO2B6vvOx", "created": 1790657029101, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb79d6b5001MbW4xoDkLv1W0a", "ordinal": 0, "delta": " to the explore subagent."}}}} +{"type": "emit_inbound", "label": "session.tool.input.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e3ee001bZpqxIZOfF4AMS", "created": 1790657029102, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb79d6b5001MbW4xoDkLv1W0a", "id": "call_function_okrf3eahp2li_1", "name": "subagent"}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e3f30014hdvE5hFQLbbjB", "created": 1790657029107, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb79d6b5001MbW4xoDkLv1W0a", "id": "call_function_okrf3eahp2li_1", "text": "{\"agent\": \"explore\", \"description\": \"List files in current directory\", \"prompt\": \"List the files in the current directory and report their names.\"}"}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e3f5001RKtt4sYhfp36nn", "created": 1790657029109, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb79d6b5001MbW4xoDkLv1W0a", "id": "call_function_okrf3eahp2li_1", "input": {"agent": "explore", "description": "List files in current directory", "prompt": "List the files in the current directory and report their names."}, "executed": false}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e3f8001O39ZEKCng9onFT", "created": 1790657029112, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb79d6b5001MbW4xoDkLv1W0a", "ordinal": 0, "text": "I'll delegate that to the explore subagent."}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 10, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e3fa001tU3lLNRop0KV3i", "created": 1790657029114, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb79d6b5001MbW4xoDkLv1W0a"}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "session.created.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e411001970UjB2ZHUAP8V", "created": 1790657029137, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "slug": "hidden-mountain", "version": "2.0.18", "projectID": "global", "parentID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "location": {"directory": ""}, "subpath": "", "title": "List files in current directory", "agent": "explore", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 0, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.progress", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e415001e6siN08G68mjcq", "created": 1790657029141, "type": "session.tool.progress", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb79d6b5001MbW4xoDkLv1W0a", "id": "call_function_okrf3eahp2li_1", "metadata": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "status": "running"}}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e417001Pg5SdtI8wMyjI7", "created": 1790657029143, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb79e416001fVwSzSq5jIVxnc", "sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "item": {"type": "user", "payload": {"text": "You are a subagent spawned by another session.\nList the files in the current directory and report their names."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e41c001MK7U872rAQZ0mZ", "created": 1790657029148, "type": "session.execution.started", "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d"}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e425001CXvSaB9nS4rdnI", "created": 1790657029157, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "delta": {"core/environment": "df20b7967ef4e6fdb6cdfb249c5c307883dfc88c9392d4fd336d4132061ab81a", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986"}}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79e427001uJRDafOXIkTr1y", "created": 1790657029159, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "inboxID": "msg_0eb79e416001fVwSzSq5jIVxnc"}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79eab00019xui5kSnOp7n6B", "created": 1790657030832, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "agent": "explore", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657029185}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79eab3001yxjXRSCICkhn6c", "created": 1790657030835, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc", "ordinal": 0}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79eb1a001juQEMB1xQwpT5p", "created": 1790657030939, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc", "ordinal": 0, "delta": "I'll list the"}}}} +{"type": "emit_inbound", "label": "session.text.delta.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ebd0001OE3pbz6kqQxlnX", "created": 1790657031120, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc", "ordinal": 0, "delta": " files in the current directory."}}}} +{"type": "emit_inbound", "label": "session.tool.input.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ebd100132ck4Jyv2KBOF4", "created": 1790657031121, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc", "id": "call_function_8gjur9skkejo_1", "name": "read"}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ebd5001K0HU9CKdC6gUg0", "created": 1790657031125, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc", "id": "call_function_8gjur9skkejo_2", "name": "glob"}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ebfa001UFybTETe4v7pdE", "created": 1790657031163, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc", "id": "call_function_8gjur9skkejo_1", "text": "{\"path\": \"\"}"}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ebfe001sh9RohNsA0zc6n", "created": 1790657031166, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc", "id": "call_function_8gjur9skkejo_1", "input": {"path": ""}, "executed": false}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 10, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ec0200156vJsHrqFavmmT", "created": 1790657031170, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc", "id": "call_function_8gjur9skkejo_2", "text": "{\"pattern\": \"*\", \"path\": \"\"}"}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ec05001WF7ETSArFYYpYc", "created": 1790657031173, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc", "id": "call_function_8gjur9skkejo_2", "input": {"pattern": "*", "path": ""}, "executed": false}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 12, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ec08001m1PPpagj5JkVzG", "created": 1790657031176, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc", "ordinal": 0, "text": "I'll list the files in the current directory."}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 13, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ec0b001J4G7Scj26CW2tw", "created": 1790657031179, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc"}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 14, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.success", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ec1c001DpVW3ZbMShwahe", "created": 1790657031196, "type": "session.tool.success", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc", "id": "call_function_8gjur9skkejo_1", "content": [{"type": "text", "text": "Read directory , entries 1-2\n.git/\nhello.txt"}], "metadata": {"truncated": false}, "executed": false}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 15, "version": 2}}}} +{"type": "emit_inbound", "label": "session.tool.success.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ec3000145jtNWia4ja0iO", "created": 1790657031216, "type": "session.tool.success", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc", "id": "call_function_8gjur9skkejo_2", "content": [{"type": "text", "text": "/hello.txt"}], "metadata": {"count": 1, "truncated": false}, "executed": false}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 16, "version": 2}}}} +{"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ec40001gMhKo6ZYqmmUgu", "created": 1790657031232, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79e42c001QmPgUwK2vEQQbc", "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 1487, "output": 85, "reasoning": 0, "cache": {"read": 222, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 17, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79ec430011Ei63W4yMsR5aJ", "created": 1790657031235, "type": "session.usage.updated", "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "cost": 0, "tokens": {"input": 1487, "output": 85, "reasoning": 0, "cache": {"read": 222, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.step.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79f0ee001NlzthsJKj5MO2i", "created": 1790657032430, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "agent": "explore", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb79ec4c001kSYzNGxpQDCtwH", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657031265}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 18, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79f0f2001zCnvBbaSmkVi2N", "created": 1790657032434, "type": "session.reasoning.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79ec4c001kSYzNGxpQDCtwH", "ordinal": 0, "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 19, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79f158001igTRKy43ppdwHo", "created": 1790657032537, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79ec4c001kSYzNGxpQDCtwH", "ordinal": 0, "delta": "The directory contains"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79f2310013KJLKeXgnGOtNq", "created": 1790657032754, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79ec4c001kSYzNGxpQDCtwH", "ordinal": 0, "delta": " a .git/ directory and hello.txt. Let me report. Maybe I should also check hidden files \u2014 the read already showed .git/ and hello.txt. Glob with"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79f2a1001YajIokrUnsOiGl", "created": 1790657032865, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79ec4c001kSYzNGxpQDCtwH", "ordinal": 0, "delta": " hidden: true could confirm."}}}} +{"type": "emit_inbound", "label": "session.tool.input.started.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79f2a2001CkaThSC3c2qAaO", "created": 1790657032866, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79ec4c001kSYzNGxpQDCtwH", "id": "call_function_s1covhpcdbtv_1", "name": "glob"}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 20, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79f2a5001uBbP5A6iA6rbRp", "created": 1790657032870, "type": "session.reasoning.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79ec4c001kSYzNGxpQDCtwH", "ordinal": 0, "text": "The directory contains a .git/ directory and hello.txt. Let me report. Maybe I should also check hidden files \u2014 the read already showed .git/ and hello.txt. Glob with hidden: true could confirm.", "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 21, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79f2a7001lOzph9veNQLCGw", "created": 1790657032871, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79ec4c001kSYzNGxpQDCtwH", "id": "call_function_s1covhpcdbtv_1", "text": "{\"path\": \"\", \"pattern\": \"*\", \"hidden\": true}"}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 22, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79f2a90010yZMJt3puMX5qw", "created": 1790657032873, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79ec4c001kSYzNGxpQDCtwH", "id": "call_function_s1covhpcdbtv_1", "input": {"path": "", "pattern": "*", "hidden": true}, "executed": false}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 23, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79f2aa001RXXWSJCdUrtVD6", "created": 1790657032874, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79ec4c001kSYzNGxpQDCtwH"}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 24, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.success.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79f2b6001sb0ftm3VIuGQY3", "created": 1790657032886, "type": "session.tool.success", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79ec4c001kSYzNGxpQDCtwH", "id": "call_function_s1covhpcdbtv_1", "content": [{"type": "text", "text": "/hello.txt"}], "metadata": {"count": 1, "truncated": false}, "executed": false}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 25, "version": 2}}}} +{"type": "emit_inbound", "label": "session.step.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79f2c1001EiR0Y21aV25wDF", "created": 1790657032897, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79ec4c001kSYzNGxpQDCtwH", "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 65, "output": 52, "reasoning": 45, "cache": {"read": 1792, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 26, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79f2c40014hzkuP8Ahr9VQX", "created": 1790657032900, "type": "session.usage.updated", "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "cost": 0, "tokens": {"input": 1552, "output": 137, "reasoning": 45, "cache": {"read": 2014, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.step.started.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79fe34001nQUoYDUh3J2JCG", "created": 1790657035828, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "agent": "explore", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb79f2cd001vTYhGkYg4egtCO", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657032929}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 27, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79fe37001QCSid878AbsmeQ", "created": 1790657035831, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79f2cd001vTYhGkYg4egtCO", "ordinal": 0}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 28, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79fe9e001WDA39xSQAFxyE0", "created": 1790657035934, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79f2cd001vTYhGkYg4egtCO", "ordinal": 0, "delta": "The current directory (`"}}}} +{"type": "emit_inbound", "label": "session.text.delta.6", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a0010001GxpoGlGU62D8ty", "created": 1790657036304, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79f2cd001vTYhGkYg4egtCO", "ordinal": 0, "delta": "`) contains:\n\n**Files:**\n- `/hello.txt`\n\n**Directories:**\n- `/.git/` (git repository metadata"}}}} +{"type": "emit_inbound", "label": "session.text.delta.7", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a01cb001drG5aU9MLeuF9d", "created": 1790657036747, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79f2cd001vTYhGkYg4egtCO", "ordinal": 0, "delta": " \u2014 its contents were not traversed)\n\nSo there is exactly one regular file, `hello.txt`. No other files (including hidden ones besides `.git`) exist at the top level."}}}} +{"type": "emit_inbound", "label": "session.text.ended.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a01cc00193PZbbldfRDCqo", "created": 1790657036748, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79f2cd001vTYhGkYg4egtCO", "ordinal": 0, "text": "The current directory (``) contains:\n\n**Files:**\n- `/hello.txt`\n\n**Directories:**\n- `/.git/` (git repository metadata \u2014 its contents were not traversed)\n\nSo there is exactly one regular file, `hello.txt`. No other files (including hidden ones besides `.git`) exist at the top level."}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 29, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a01cf001C1A0eAbE50IXZY", "created": 1790657036751, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79f2cd001vTYhGkYg4egtCO"}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 30, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a01db001t7AosrQjjpbNa2", "created": 1790657036763, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "assistantMessageID": "msg_0eb79f2cd001vTYhGkYg4egtCO", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 1687, "output": 102, "reasoning": 0, "cache": {"read": 298, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 31, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a01df001X6khu80Qk80v65", "created": 1790657036767, "type": "session.usage.updated", "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "cost": 0, "tokens": {"input": 3239, "output": 239, "reasoning": 45, "cache": {"read": 2312, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a01e1001CTat4KzrjA5TmS", "created": 1790657036769, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d"}, "durable": {"aggregateID": "ses_f14861bfdffea1l0D7wDj5no7d", "seq": 32, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.success.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a01e70013EpNtFOD355MJp", "created": 1790657036775, "type": "session.tool.success", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb79d6b5001MbW4xoDkLv1W0a", "id": "call_function_okrf3eahp2li_1", "content": [{"type": "text", "text": "\nThe current directory (``) contains:\n\n**Files:**\n- `/hello.txt`\n\n**Directories:**\n- `/.git/` (git repository metadata \u2014 its contents were not traversed)\n\nSo there is exactly one regular file, `hello.txt`. No other files (including hidden ones besides `.git`) exist at the top level.\n"}], "metadata": {"sessionID": "ses_f14861bfdffea1l0D7wDj5no7d", "status": "completed", "truncated": false}, "executed": false}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 12, "version": 2}}}} +{"type": "emit_inbound", "label": "session.step.ended.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a01fc0012CxFbxpR16Xvvl", "created": 1790657036796, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb79d6b5001MbW4xoDkLv1W0a", "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 8783, "output": 70, "reasoning": 0, "cache": {"read": 486, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 13, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a0200001UMPOOG67uTYObH", "created": 1790657036800, "type": "session.usage.updated", "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "cost": 0, "tokens": {"input": 8783, "output": 70, "reasoning": 0, "cache": {"read": 486, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.step.started.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a05ce001bFaPDJW13tZLYh", "created": 1790657037774, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7a020e001FvO5vpqY1k542k", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657036835}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 14, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a05d10016ZKPCCTaYftMpK", "created": 1790657037777, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb7a020e001FvO5vpqY1k542k", "ordinal": 0}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 15, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.8", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a0637001N1HL7uTp4D1SeK", "created": 1790657037879, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb7a020e001FvO5vpqY1k542k", "ordinal": 0, "delta": "The current directory contains a"}}}} +{"type": "emit_inbound", "label": "session.text.delta.9", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a06820011Ra9XKv6he5pfo", "created": 1790657037954, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb7a020e001FvO5vpqY1k542k", "ordinal": 0, "delta": " single file, `hello.txt` (plus the `.git` directory)."}}}} +{"type": "emit_inbound", "label": "session.text.ended.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a0683001LBfBoOAJXtWQJw", "created": 1790657037955, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb7a020e001FvO5vpqY1k542k", "ordinal": 0, "text": "The current directory contains a single file, `hello.txt` (plus the `.git` directory)."}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 16, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a0686001N2XAcKK51W7OF3", "created": 1790657037958, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb7a020e001FvO5vpqY1k542k"}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 17, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a06920014FUSRw4f5HqoMs", "created": 1790657037970, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "assistantMessageID": "msg_0eb7a020e001FvO5vpqY1k542k", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 151, "output": 21, "reasoning": 0, "cache": {"read": 9337, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 18, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a06960014XigjtDbzXsNlP", "created": 1790657037974, "type": "session.usage.updated", "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "cost": 0, "tokens": {"input": 8934, "output": 91, "reasoning": 0, "cache": {"read": 9823, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a0698001WqfrZ1G3FxC1Js", "created": 1790657037976, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R"}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 19, "version": 1}}}} +{"type": "runtime_exit", "status": "success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/output.ts new file mode 100644 index 000000000000..cc7d96fbfdaa --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/output.ts @@ -0,0 +1,74 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertNoExtraAppRunsForProviderChildren, + assertProviderNativeSubagentRootTurns, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + OPENCODE2_SUBAGENT_PROMPT, + projectionFor, +} from "../shared.ts"; + +const CHILD_PROMPT = "List the files in the current directory and report their names."; + +/** + * A foreground `subagent` call: the child session is a subagent thread with + * its own runless turn, its tools and answer stay there, and the parent's + * turn waits for it before summarizing. + */ +export function assertOpenCode2SubagentOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertNoExtraAppRunsForProviderChildren({ projection, expectedAppRuns: 1 }); + assertProviderNativeSubagentRootTurns(result); + assertUserMessagesInclude(projection, [OPENCODE2_SUBAGENT_PROMPT]); + assertAssistantTextIncludes(projection, "hello.txt"); + + assert.lengthOf(projection.subagents, 1); + const subagent = projection.subagents[0]!; + assert.deepInclude(subagent, { + origin: "provider_native", + status: "completed", + prompt: CHILD_PROMPT, + title: "List files in current directory", + runId: projection.runs[0]!.id, + }); + assert.include(subagent.result ?? "", "hello.txt"); + assert.notInclude(subagent.result ?? "", " (item.type === "subagent" ? [item.status] : [])), + ["completed"], + ); + assert.isFalse( + projection.turnItems.some( + (item) => item.type === "dynamic_tool" || item.type === "file_search", + ), + "the child's read and glob calls must not land on the parent", + ); + + const child = result.projections.get(subagent.childThreadId!); + assert.isDefined(child); + assert.equal(child.thread.lineage.relationshipToParent, "subagent"); + assert.deepEqual( + child.providerTurns.map((turn) => turn.status), + ["completed"], + ); + assertUserMessagesInclude(child, [CHILD_PROMPT]); + assert.isAtLeast( + child.turnItems.filter((item) => item.type === "dynamic_tool" || item.type === "file_search") + .length, + 2, + ); + assertAssistantTextIncludes(child, "hello.txt"); + // The parent's usage is its own session's; it notes that a subagent ran. + assert.equal(projection.providerTurns[0]?.turnTokenUsage?.hasSubagents, true); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index fd0ad6acf995..a9f8393d2d39 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -75,6 +75,10 @@ export const OPENCODE2_PERMISSION_PROMPT = "Run the shell command `echo FIRST` with the bash tool. After it completes, run `echo SECOND` with the bash tool. Then reply with what happened."; export const OPENCODE2_QUESTION_PROMPT = "Before doing anything, use the question tool to ask me which color I prefer, offering the options red and blue. After I answer, reply with only the chosen color."; +export const OPENCODE2_SUBAGENT_PROMPT = + "Use the subagent tool to delegate to the explore subagent with the prompt: 'List the files in the current directory and report their names.' Wait for it, then summarize its answer in one line."; +export const OPENCODE2_BACKGROUND_PROMPT = + "Use the subagent tool with background enabled to delegate to the general subagent with the prompt: 'Run the shell command `sleep 20` with the bash tool and then reply exactly CHILD_OK.' As soon as it is launched, reply exactly PARENT_OK and end your turn without waiting for it."; export const TURN_INTERRUPT_PROMPT = "Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."; export const TURN_INTERRUPT_MID_TOOL_PROMPT = From a0d1bbb77bb52cff524725de0ba757fc6be70503 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:37:13 -0700 Subject: [PATCH 28/34] feat(server): OpenCode 2 native steering, fork and rollback Carries pingdotgg/t3code#14468 oc2-7-inbox-fork-revert unchanged, squashed to one commit with Julius Marminge's authorship. Drop this layer once it merges into codex-turn-mapping. --- .../Adapters/OpenCode2AdapterV2.test.ts | 258 ++++++++ .../Adapters/OpenCode2AdapterV2.testkit.ts | 46 +- .../Adapters/OpenCode2AdapterV2.ts | 560 +++++++++++++++--- .../Adapters/OpenCodeAdapterV2.testkit.ts | 37 +- ...penCode2OrchestratorV2.integration.test.ts | 158 +++++ .../OpenCode2OrchestratorV2.live.test.ts | 214 ++++++- .../testkit/fixtures/index.ts | 34 ++ .../opencode2_fork/opencode_transcript.ndjson | 65 ++ .../testkit/fixtures/opencode2_inbox/input.ts | 24 + .../opencode_transcript.ndjson | 51 ++ .../fixtures/opencode2_inbox/output.ts | 72 +++ .../fixtures/opencode2_revert/input.ts | 17 + .../opencode_transcript.ndjson | 66 +++ .../fixtures/opencode2_revert/output.ts | 39 ++ .../testkit/fixtures/shared.ts | 9 + 15 files changed, 1542 insertions(+), 108 deletions(-) create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/opencode_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/opencode_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/output.ts diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 44bb6a8958cd..41c607a4b26e 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -5,11 +5,13 @@ */ import { assert, it } from "@effect/vitest"; import { + CheckpointId, MessageId, NodeId, ProviderInstanceId, ProviderSessionId, ProviderThreadId, + ProviderTurnId, RunAttemptId, RunId, ThreadId, @@ -34,6 +36,7 @@ import type { ProviderAdapterV2SessionRuntime, } from "../ProviderAdapter.ts"; import * as IdAllocator from "../IdAllocator.ts"; +import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; import { OPENCODE_2_STILL_STOPPING } from "./OpenCode2AdapterV2.ts"; @@ -1664,6 +1667,261 @@ describe("OpenCode2 adapter", () => { assert.equal(runtime.getModelContextWindow?.(bigPickle), 160000); }).pipe(Effect.scoped), ); + + it.effect("clears a staged revert whose commit failed, before the next prompt too", () => + Effect.gen(function* () { + const kept = "msg_t3_turn_run-attempt:kept:1"; + const dropped = "msg_t3_turn_run-attempt:dropped:1"; + const { runtime, thread } = yield* resumed([ + out("message.list", ""), + reply("message.list", { + data: [ + { id: dropped, time: { created: 2 }, text: "second", type: "user" }, + { id: kept, time: { created: 1 }, text: "first", type: "user" }, + ], + cursor: {}, + }), + out("session.revert.stage", { sessionID: SESSION, messageID: dropped, files: false }), + replyData("session.revert.stage", { messageID: dropped, files: [] }), + out("session.revert.commit", { sessionID: SESSION }), + reply("session.revert.commit", { + status: 500, + body: { _tag: "UnknownError", message: "disk I/O error" }, + }), + // Uncleared, OpenCode would commit the stage on the next prompt. + out("session.revert.clear", { sessionID: SESSION }), + reply("session.revert.clear", { + status: 500, + body: { _tag: "UnknownError", message: "busy" }, + }), + // The next turn clears it first, then waits out the empty execution `clear` runs. + out("session.revert.clear", { sessionID: SESSION }), + reply("session.revert.clear", null), + event("session.execution.started", { sessionID: SESSION }), + event("session.execution.succeeded", { sessionID: SESSION }), + out("session.prompt", { sessionID: SESSION, id: "", text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const now = yield* DateTime.now; + const turn = (attempt: string, ordinal: number) => ({ + id: ProviderTurnId.make(`provider-turn:${attempt}`), + providerThreadId: thread.id, + nodeId: NodeId.make(`node:${attempt}`), + runAttemptId: RunAttemptId.make(`run-attempt:${attempt}:1`), + nativeTurnRef: { + driver: OPENCODE_PROVIDER, + nativeId: `msg_t3_turn_run-attempt:${attempt}:1`, + strength: "weak" as const, + }, + ordinal, + status: "completed" as const, + startedAt: now, + completedAt: now, + }); + const rollback = yield* runtime + .rollbackThread({ + providerThread: thread, + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint:kept"), + appRunOrdinal: 1, + providerTurn: turn("kept", 1), + }, + providerThreadTurns: [turn("kept", 1), turn("dropped", 2)], + }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(rollback)); + yield* runtime.startTurn(turnInput(thread)); + assert.equal((yield* terminalOf(runtime))?.status, "completed"); + }).pipe(Effect.scoped), + ); + + it.effect("offers no follow-up for the execution a cleared revert starts", () => + Effect.gen(function* () { + const kept = "msg_t3_turn_run-attempt:kept:1"; + const dropped = "msg_t3_turn_run-attempt:dropped:1"; + const offers: Array = []; + const { runtime, thread } = yield* resumed([ + out("message.list", ""), + reply("message.list", { + data: [ + { id: dropped, time: { created: 2 }, text: "second", type: "user" }, + { id: kept, time: { created: 1 }, text: "first", type: "user" }, + ], + cursor: {}, + }), + out("session.revert.stage", { sessionID: SESSION, messageID: dropped, files: false }), + replyData("session.revert.stage", { messageID: dropped, files: [] }), + out("session.revert.commit", { sessionID: SESSION }), + reply("session.revert.commit", { + status: 500, + body: { _tag: "UnknownError", message: "disk I/O error" }, + }), + // Uncleared, OpenCode would commit the stage on the next prompt. The + // clear wakes the session into an empty execution of its own, with no + // turn of T3's running: it is no subagent's follow-up. + out("session.revert.clear", { sessionID: SESSION }), + reply("session.revert.clear", null), + event("session.revert.cleared", { sessionID: SESSION }), + event("session.execution.started", { sessionID: SESSION }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]).pipe( + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: (request) => Effect.sync(() => void offers.push(request)), + take: Effect.never, + }), + ); + const now = yield* DateTime.now; + const turn = (attempt: string, ordinal: number) => ({ + id: ProviderTurnId.make(`provider-turn:${attempt}`), + providerThreadId: thread.id, + nodeId: NodeId.make(`node:${attempt}`), + runAttemptId: RunAttemptId.make(`run-attempt:${attempt}:1`), + nativeTurnRef: { + driver: OPENCODE_PROVIDER, + nativeId: `msg_t3_turn_run-attempt:${attempt}:1`, + strength: "weak" as const, + }, + ordinal, + status: "completed" as const, + startedAt: now, + completedAt: now, + }); + const rollback = yield* runtime + .rollbackThread({ + providerThread: thread, + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint:kept"), + appRunOrdinal: 1, + providerTurn: turn("kept", 1), + }, + providerThreadTurns: [turn("kept", 1), turn("dropped", 2)], + }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(rollback)); + assert.lengthOf(offers, 0); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + }).pipe(Effect.scoped), + ); + + it.effect("ends a follow-up turn a steer joined exactly once", () => + Effect.gen(function* () { + const reportText = `\nCHILD_OK\n`; + const steerId = "msg_t3_steer_message:opencode2-adapter:steer"; + const offers: Array = []; + const { runtime, thread } = yield* resumed([ + ...backgroundLaunch(CHILD), + event("session.execution.succeeded", { sessionID: SESSION }), + event("session.execution.succeeded", { sessionID: CHILD }), + event("session.inbox.enqueued", { + inboxID: "msg_report", + sessionID: SESSION, + item: { + type: "synthetic", + payload: { + text: reportText, + description: "Sleep", + metadata: { + source: "subagent", + childID: CHILD, + agent: "General", + state: "completed", + }, + }, + delivery: "steer", + }, + }), + // OpenCode starts the follow-up on its own; T3 holds it for its turn. + event("session.execution.started", { sessionID: SESSION }), + event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_report" }), + // The user steers into the follow-up turn; its execution reads the steer. + out("session.prompt", { + sessionID: SESSION, + id: steerId, + text: "", + delivery: "steer", + }), + replyData("session.prompt", { + id: steerId, + sessionID: SESSION, + time: { created: 1790656601500 }, + type: "user", + payload: { text: "Also say STEERED." }, + delivery: "steer", + }), + event("session.inbox.delivered", { sessionID: SESSION, inboxID: steerId }), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_followup", + ordinal: 0, + text: "CHILD_OK STEERED", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]).pipe( + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: (request) => Effect.sync(() => void offers.push(request)), + take: Effect.never, + }), + ); + const terminals: Array = []; + const followUpEnded = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.tap((event) => + Effect.gen(function* () { + if (event.type !== "turn.terminal") return; + terminals.push(event.status); + if (terminals.length === 2) yield* Deferred.succeed(followUpEnded, undefined); + }), + ), + Stream.runDrain, + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + // The launching turn ends; then the held follow-up is offered and taken. + yield* Effect.gen(function* () { + while (offers.length === 0) yield* Effect.yieldNow; + }).pipe(Effect.timeout("2 seconds"), Effect.orDie); + const followUp = { + ...withLineage(thread), + runId: RunId.make("run:opencode2-adapter:wake"), + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make("attempt:opencode2-adapter:wake"), + message: { + ...turnInput(thread).message, + messageId: MessageId.make("message:opencode2-adapter:wake"), + createdBy: "agent" as const, + creationSource: "provider" as const, + }, + }; + yield* runtime.startTurn(followUp); + const followUpTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: OPENCODE_PROVIDER, + nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter:wake`, + }); + yield* runtime.steerTurn({ + threadId, + runId: followUp.runId, + providerThread: thread, + providerTurnId: followUpTurnId, + message: { + ...turnInput(thread).message, + messageId: MessageId.make("message:opencode2-adapter:steer"), + text: "Also say STEERED.", + }, + }); + yield* Deferred.await(followUpEnded); + // The launching turn and the steered follow-up end once each. The + // replay has no frame after the follow-up's end, so a second end of the + // follow-up (or another continuation) would show here or fail the replay. + yield* Effect.yieldNow; + assert.deepEqual(terminals, ["completed", "completed"]); + assert.lengthOf(offers, 1); + }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)), + ); }); /** The provider turn the adapter derives for `turnInput`'s attempt. */ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index c92db1d577c6..d9f7457f2e3d 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -25,6 +25,7 @@ import * as OpenCode2Client from "../../provider/opencode2/OpenCode2Client.ts"; import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts"; import * as IdAllocator from "../IdAllocator.ts"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; +import type { ProviderReplayGate } from "../testkit/ProviderReplayGate.testkit.ts"; import { makeReplayServerConfig, type OrchestratorV2ProviderReplayHarness, @@ -75,6 +76,18 @@ const operationOf = ( if (method === "POST" && rest === "/model") return { type: "session.switchModel", input }; if (method === "POST" && rest === "/move") return { type: "session.move", input }; if (method === "POST" && rest === "/synthetic") return { type: "session.synthetic", input }; + if (method === "POST" && rest === "/fork") return { type: "session.fork", input }; + if (method === "POST" && rest === "/revert/stage") { + return { type: "session.revert.stage", input }; + } + if (method === "POST" && rest === "/revert/commit") { + return { type: "session.revert.commit", input }; + } + if (method === "DELETE" && rest === "/revert") return { type: "session.revert.clear", input }; + const inbox = /^\/inbox\/([^/]+)$/.exec(rest); + if (method === "DELETE" && inbox !== null) { + return { type: "session.inbox.cancel", input: { ...input, inboxID: inbox[1] } }; + } if (method === "GET" && rest === "/message") return { type: "message.list", input }; if (method === "GET" && rest === "/permission") return { type: "permission.list", input }; if (method === "GET" && rest === "/form") return { type: "session.form.list", input }; @@ -95,7 +108,10 @@ const operationOf = ( }; /** An `HttpClient` that answers every request from the transcript. */ -const replayHttpClient = (controller: OpenCodeReplayController) => +const replayHttpClient = ( + controller: OpenCodeReplayController, + replayGate: ProviderReplayGate | undefined, +) => HttpClient.make((request, url) => Effect.tryPromise({ try: async () => { @@ -111,12 +127,13 @@ const replayHttpClient = (controller: OpenCodeReplayController) => raw === undefined ? undefined : decodeJson(raw), ); // The event stream and requests are separate connections: a request - // is matched only once the events recorded before it were delivered. - if (operation.type !== "event.subscribe") await controller.untilEventsDelivered(); + // is matched only once what was recorded before it was delivered. + if (operation.type !== "event.subscribe") await controller.untilInboundDelivered(); await controller.expectOutbound(operation); if (operation.type === "event.subscribe") { const encoder = new TextEncoder(); - const frames = controller.events()[Symbol.asyncIterator](); + const events = controller.events(undefined, replayGate?.beforeEmit); + const frames = events[Symbol.asyncIterator](); const body = new ReadableStream({ async pull(stream) { const next = await frames.next(); @@ -150,13 +167,21 @@ const replayHttpClient = (controller: OpenCodeReplayController) => /** The 2.x adapter over a replayed server, checking at scope close that the transcript ran out. */ const makeReplayAdapter = ( transcript: ProviderReplayTranscript, - options?: { external?: boolean }, + options?: { readonly external?: boolean; readonly replayGate?: ProviderReplayGate }, ) => Effect.gen(function* () { const controller = new OpenCodeReplayController(transcript); - yield* Effect.addFinalizer(() => Effect.sync(() => controller.assertComplete())); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + options?.replayGate?.releaseAll(); + controller.assertComplete(); + }), + ); const opencode = yield* OpenCode2Client.make.pipe( - Effect.provideService(HttpClient.HttpClient, replayHttpClient(controller)), + Effect.provideService( + HttpClient.HttpClient, + replayHttpClient(controller, options?.replayGate), + ), ); const connection = { ...(yield* opencode.connect({ baseUrl: BASE_URL, password: "replay" })), @@ -177,9 +202,12 @@ const replayServerConfig = (scenario: string) => Layer.provide(NodeServices.layer), ); -function makeRegistryLayer(transcript: OpenCode2ReplayTranscript) { +function makeRegistryLayer( + transcript: OpenCode2ReplayTranscript, + options?: { readonly replayGate?: ProviderReplayGate }, +) { return Layer.unwrap( - makeReplayAdapter(transcript, { external: true }).pipe( + makeReplayAdapter(transcript, { external: true, ...options }).pipe( Effect.map((adapter) => ProviderAdapterRegistry.makeLayer([adapter])), ), ).pipe(Layer.provide(Layer.mergeAll(replayServerConfig(transcript.scenario), IdAllocator.layer))); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index 4ea6b997e1ae..2ce951a4f036 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -4,15 +4,19 @@ * server's `/api/event` stream, routed here by session id. * * A turn is one `session.prompt`; the session's next `session.execution.*` - * terminal ends it. Each runtime mode is a set of session permission rules, - * and OpenCode's permission asks and question forms become runtime requests - * on the asking session's thread, a subagent's on its parent's. + * terminal ends it. A steer is another prompt with `delivery: "steer"`, which + * OpenCode reads at the running execution's next step boundary, so it ends + * with the turn it joined. Fork and rollback cut the native history before the + * user message a turn prompted with (its `nativeTurnRef`). Each runtime mode is + * a set of session permission rules, and OpenCode's permission asks and + * question forms become runtime requests on the asking session's thread, a + * subagent's on its parent's. * * A `subagent` call runs in a child session shown as a subagent thread. A * background one outlives its turn; when it ends, OpenCode starts a parent * execution T3 did not ask for, which waits here for the continuation turn T3 - * opens for it. Steering, fork, rollback and compaction arrive in later - * layers, and the capabilities below say no. + * opens for it. Compaction arrives in a later layer, and the capabilities + * below say no. * * @module orchestration-v2/Adapters/OpenCode2AdapterV2 */ @@ -24,6 +28,7 @@ import { Permission, Provider, Session, + SessionMessage, type OpenCodeEvent, } from "@opencode/client/effect"; import { @@ -96,9 +101,9 @@ const OpenCode2ProviderCapabilities = { threads: { canCreateEmptyThread: true, canReadThreadSnapshot: true, - canRollbackThread: false, - canForkThread: false, - canForkFromTurn: false, + canRollbackThread: true, + canForkThread: true, + canForkFromTurn: true, canForkFromSubagentThread: false, exposesNativeThreadId: true, }, @@ -107,8 +112,10 @@ const OpenCode2ProviderCapabilities = { emitsTurnStarted: true, emitsTurnCompleted: true, supportsInterrupt: true, - supportsActiveSteering: false, + supportsActiveSteering: true, supportsSteeringByInterruptRestart: true, + // T3 holds queued messages and starts each as its own turn once the one + // before it ends, so OpenCode's own `queue` delivery is never used. supportsQueuedMessages: true, terminalStatusQuality: "strong", }, @@ -164,8 +171,8 @@ const OpenCode2ProviderCapabilities = { checkpointing: { appCanCheckpointFilesystem: true, supportsNestedCheckpointScopes: true, - providerCanRollbackConversation: false, - providerRollbackReturnsSnapshot: false, + providerCanRollbackConversation: true, + providerRollbackReturnsSnapshot: true, providerCanReadConversationSnapshot: true, }, identity: { @@ -211,6 +218,16 @@ interface ActiveTurn { steps: number; lastStep: Tokens | undefined; interrupted: boolean; + /** + * Steers sent into this turn that OpenCode has not delivered yet, by inbox + * id. Each one wakes the session, so an execution that ends before reading + * one is followed by another that does, and the turn spans both. + */ + readonly steers: Set; + /** This turn's steers OpenCode delivered or cancelled: a retried one is not sent again. */ + readonly settledInbox: Set; + /** How the last execution ended, while the turn waits for the one that delivers its steers. */ + heldEnd: TurnTerminal | undefined; /** * Set on a turn started after a timed-out Stop's run left the server: that * run's tail can still be on the stream, and everything before this turn's @@ -323,6 +340,11 @@ interface ThreadState { * report them; a wake that reports only these is stopped as well. */ readonly stoppedChildren: Set; + /** + * Steers a turn ended without (a Stop, a failure). They stay in OpenCode's + * inbox, where the next prompt would deliver them first, so it cancels them. + */ + readonly strandedSteers: Set; } type TurnTerminal = @@ -493,13 +515,6 @@ const agentPaths = (rules: ReadonlyArray) => (entry.action === "edit" || entry.action === "external_directory"), ); -/** One wording for every capability later layers add. */ -const notYet = (feature: string) => - new ProviderAdapter.ProviderAdapterProtocolError({ - driver: OPENCODE_PROVIDER, - detail: `OpenCode 2 ${feature} is not supported yet`, - }); - const ref = (nativeId: string, strength: "strong" | "weak" = "strong") => ({ driver: OPENCODE_PROVIDER, nativeId, @@ -543,6 +558,11 @@ const subagentOutput = (text: string) => const isContinuation = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => turnInput.message.createdBy === "agent" && turnInput.message.creationSource === "provider"; +/** The native turn id of a continuation turn, which answers a wake and has no prompt. */ +const wakeTurnId = (sessionId: string, attemptId: string) => `${sessionId}:wake:${attemptId}`; +const isWakeTurn = (turn: OrchestrationV2ProviderTurn) => + turn.nativeTurnRef?.nativeId?.includes(":wake:") === true; + const INTERRUPT_TIMEOUT = "10 seconds"; const ACTIVE_CHECK_TIMEOUT = "5 seconds"; /** Answers that mean the server refused a prompt; any other failure may have been accepted. */ @@ -567,6 +587,61 @@ const deliver = (answer: Effect.Effect) => type ModelRef = ReturnType; +/** + * The user message ids T3 prompts under. OpenCode takes a client id (it must + * start with `msg_`) and answers a repeat of one with the item it already has, + * so a retried request never queues a second message, and a turn knows the + * message fork and rollback cut at before OpenCode answers. + */ +const turnPromptId = (attemptId: string) => SessionMessage.ID.make(`msg_t3_turn_${attemptId}`); +const steerPromptId = (messageId: string) => SessionMessage.ID.make(`msg_t3_steer_${messageId}`); + +/** + * The user message a turn prompted with. Turns from before T3 chose prompt ids + * recorded `:attempt:`, which is no message. + */ +const promptOf = (turn: OrchestrationV2ProviderTurn) => { + const nativeId = turn.nativeTurnRef?.nativeId; + return nativeId?.startsWith("msg_") === true ? nativeId : undefined; +}; + +/** + * Where fork and rollback cut to keep the session's turns up to `kept` (all of + * them before the first turn when undefined): before the prompt of the next + * turn that reached OpenCode, whose message is in `prompts`. A turn refused + * before it prompted is not in the session and is passed over, and so is a + * continuation turn, which OpenCode started without a prompt of T3's. `null` + * means nothing follows, so there is no cut; a later turn from before T3 + * chose prompt ids has no known message, so no cut is safe. + */ +const boundaryAfter = ( + turns: ReadonlyArray, + providerThreadId: OrchestrationV2ProviderThread["id"], + kept: OrchestrationV2ProviderTurn | undefined, + prompts: ReadonlySet, +) => { + const later = turns + .filter( + (turn) => + turn.providerThreadId === providerThreadId && + (kept === undefined || turn.ordinal > kept.ordinal), + ) + .toSorted((left, right) => left.ordinal - right.ordinal); + if (later.some((turn) => promptOf(turn) === undefined && !isWakeTurn(turn))) { + return Effect.fail( + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: + "This OpenCode conversation has turns from an earlier T3 Code version, so it can't be cut there.", + }), + ); + } + return Effect.succeed( + later.map(promptOf).find((messageId) => messageId !== undefined && prompts.has(messageId)) ?? + null, + ); +}; + // Errors already in the adapter channel keep their tag; only lower-level ones are wrapped. const isProviderAdapterError = Schema.is(ProviderAdapter.ProviderAdapterV2Error); @@ -658,6 +733,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const pending = new Map(); // Events and a wake's replay into its turn are handled one at a time, in order. const lock = yield* Semaphore.make(1); + // Sessions whose `revert.clear` has not had its empty execution yet. + const clearing = new Map>(); + // Sessions a failed rollback may have left with a staged revert. + const stagedReverts = new Set(); const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); const ownerOf = (sessionId: string) => childOwners.get(sessionId) ?? threads.get(sessionId); @@ -684,6 +763,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr wakes: [], reports: new Map(), stoppedChildren: new Set(), + strandedSteers: new Set(), }); /** The thread whose session started this one, through any nesting. */ @@ -942,6 +1022,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr steps: 0, lastStep: undefined, interrupted: false, + steers: new Set(), + settledInbox: new Set(), + heldEnd: undefined, awaitingStart: options.awaitingStart, scope: options.scope, usedSubagents: false, @@ -1305,6 +1388,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const turn = state.active; if (turn === undefined) return; state.active = undefined; + for (const inboxID of turn.steers) state.strandedSteers.add(inboxID); // OpenCode drops a request when the asking session's execution ends. A // subagent's request shown on this turn ends with it too, unless the // subagent runs in the background past a turn that did not fail. @@ -1869,14 +1953,18 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr turn.usage.reasoning += tokens.reasoning; return; } + case "session.execution.started": + // The execution that delivers steers the last one ended without. + turn.heldEnd = undefined; + return; case "session.execution.succeeded": - return yield* finishTurn(state, { + return yield* endExecution(state, turn, { status: turn.interrupted ? "interrupted" : "completed", }); case "session.execution.interrupted": - return yield* finishTurn(state, { status: "interrupted" }); + return yield* endExecution(state, turn, { status: "interrupted" }); case "session.execution.failed": - return yield* finishTurn(state, { + return yield* endExecution(state, turn, { status: "failed", failure: makeProviderFailure({ message: event.data.error.message, @@ -1987,8 +2075,46 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr : undefined; }; + /** + * Ends the turn with its execution, unless a steer sent into it is still + * undelivered: OpenCode then starts another execution for it, which the + * turn owns too. A Stop leaves steers undelivered and starts nothing. + */ + const endExecution = Effect.fnUntraced(function* ( + state: ThreadState, + turn: ActiveTurn, + end: TurnTerminal, + ) { + if (end.status === "completed" && turn.steers.size > 0) { + turn.heldEnd = end; + return; + } + yield* finishTurn(state, end); + }); + + /** Ends a turn held for its steers once none is left to deliver. */ + const endIfSettled = (state: ThreadState, turn: ActiveTurn) => + turn.heldEnd !== undefined && turn.steers.size === 0 + ? finishTurn(state, turn.heldEnd) + : Effect.void; + const handleEvent = Effect.fnUntraced(function* (event: OpenCode2StreamEvent) { const sessionId = sessionOfEvent(event); + // `revert.clear` wakes the session into an empty execution of its own + // (2.0.18's `Session.revert.clear` ends with a wake). It is no run of + // T3's, and no follow-up to a subagent either. + const cleared = sessionId === undefined ? undefined : clearing.get(sessionId); + if (cleared !== undefined) { + if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) { + return yield* Deferred.succeed(cleared, undefined); + } + if ( + event.type === "session.execution.started" || + event.type === "unreadable.execution.started" + ) { + return; + } + } if (sessionId !== undefined) { if ( event.type === "session.execution.started" || @@ -2045,16 +2171,19 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const turn = threads.get(event.sessionID)?.active; if (turn !== undefined) { turn.awaitingStart = false; + turn.heldEnd = undefined; return; } } if (event.type === "unreadable.execution.ended") { const state = threads.get(event.sessionID); - if (state === undefined || state.active?.awaitingStart === true) return; - return yield* finishTurn( + const turn = state?.active; + if (state === undefined || turn === undefined || turn.awaitingStart) return; + return yield* endExecution( state, + turn, event.executionType === "session.execution.succeeded" - ? { status: state.active?.interrupted === true ? "interrupted" : "completed" } + ? { status: turn.interrupted ? "interrupted" : "completed" } : event.executionType === "session.execution.interrupted" ? { status: "interrupted" } : { @@ -2110,8 +2239,15 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return yield* onReport(state, event.data.inboxID, event.data.item.payload); } // Cancelled, or delivered into a turn already running: no wake reports it. + // A steer of the running turn's is settled the same way. if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") { state.reports.delete(event.data.inboxID); + const turn = state.active; + if (turn !== undefined && turn.steers.delete(event.data.inboxID)) { + turn.settledInbox.add(event.data.inboxID); + // A cancelled steer may leave a held turn nothing to wait for. + if (event.type === "session.inbox.cancelled") yield* endIfSettled(state, turn); + } return; } // A subagent's later prompts (a resumed subagent) open its next turn's message. @@ -2311,6 +2447,133 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return state; }; + /** Takes back steers a finished turn left in OpenCode's inbox. */ + const cancelStrandedSteers = Effect.fnUntraced(function* (state: ThreadState) { + const sessionID = Session.ID.make(state.sessionId); + yield* Effect.forEach( + state.strandedSteers, + (inboxID) => + client.session.inbox + .cancel({ sessionID, inboxID: SessionMessage.ID.make(inboxID) }) + .pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT), Effect.ignore({ log: true })), + { discard: true }, + ); + state.strandedSteers.clear(); + }); + + /** + * Clears a staged revert and waits out the empty execution `clear` runs, + * which is no turn of T3's. Clearing with nothing staged is a no-op. + */ + const clearRevert = Effect.fnUntraced(function* (sessionId: string) { + const settled = yield* Deferred.make(); + clearing.set(sessionId, settled); + yield* client.session.revert + .clear({ sessionID: Session.ID.make(sessionId) }) + .pipe( + Effect.andThen(Deferred.await(settled)), + Effect.timeout(REQUEST_REPLY_TIMEOUT), + Effect.ensuring(Effect.sync(() => clearing.delete(sessionId))), + ); + stagedReverts.delete(sessionId); + }); + + /** + * Truncates the session's history before `boundary`: OpenCode stages the + * revert, then commits it. If either fails or is interrupted the stage is + * cleared, since OpenCode commits a staged revert on the next prompt; a + * clear that fails too is tried again before that prompt. Files stay where + * they are: T3 restores its own checkpoint when the user asked for files, + * and a rewind without files must not touch them. + */ + const rollBackTo = Effect.fnUntraced(function* ( + sessionId: string, + boundary: SessionMessage.ID, + ) { + const sessionID = Session.ID.make(sessionId); + yield* client.session.revert.stage({ sessionID, messageID: boundary, files: false }).pipe( + Effect.andThen(client.session.revert.commit({ sessionID })), + Effect.onError(() => + clearRevert(sessionId).pipe( + Effect.tapError(() => Effect.sync(() => stagedReverts.add(sessionId))), + Effect.ignore({ log: true }), + ), + ), + ); + }); + + /** The ids of the session's user messages: the prompts a cut can land on. */ + const userMessages = (sessionId: string) => + paginate( + { sessionID: Session.ID.make(sessionId), type: "user" as const, limit: 100 }, + client.message.list, + ).pipe( + Stream.runCollect, + Effect.map( + (messages) => + new Set( + messages.flatMap((message) => (message.type === "user" ? [String(message.id)] : [])), + ), + ), + ); + + /** The session's history as V2 messages, read after any change T3 made to it. */ + const snapshotOf = Effect.fnUntraced(function* ( + providerThread: OrchestrationV2ProviderThread, + sessionId: string, + ) { + const history = yield* paginate( + { sessionID: Session.ID.make(sessionId), order: "asc" as const, limit: 100 }, + client.message.list, + ).pipe(Stream.runCollect); + const snapshotAt = yield* DateTime.now; + const messages = history.flatMap((message): Array => { + const text = + message.type === "user" + ? message.text + : message.type === "assistant" + ? textOf(message.content) + : ""; + if (text.length === 0 || (message.type !== "user" && message.type !== "assistant")) { + return []; + } + const createdAt = message.time.created; + return [ + { + createdBy: message.type === "user" ? "user" : "agent", + creationSource: "provider", + id: idAllocator.derive.messageFromProviderItem({ + driver, + nativeItemId: message.id, + }), + threadId: providerThread.appThreadId ?? input.threadId, + runId: null, + nodeId: null, + role: message.type, + text, + attachments: [], + streaming: false, + createdAt, + updatedAt: createdAt, + }, + ]; + }); + const lastUser = history.findLast((message) => message.type === "user")?.id; + const state = threads.get(sessionId); + return { + providerThread: { + ...providerThread, + providerSessionId: input.providerSessionId, + nativeConversationHeadRef: lastUser === undefined ? null : ref(lastUser, "weak"), + status: "idle" as const, + updatedAt: snapshotAt, + }, + providerTurns: state === undefined ? [] : [...state.providerTurns.values()], + messages, + runtimeRequests: [], + }; + }); + const prompt = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => { const text = providerMessageTextWithAttachmentPaths({ text: turnInput.message.text, @@ -2338,7 +2601,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr providerThreadId: turnInput.providerThread.id, nodeId: turnInput.rootNodeId, runAttemptId: turnInput.attemptId, - nativeTurnRef: ref(nativeTurnId, "weak"), + // The user message it prompts with, where fork and rollback cut. A + // continuation prompts nothing, so it has no message to cut at. + nativeTurnRef: ref( + isContinuation(turnInput) + ? wakeTurnId(state.sessionId, turnInput.attemptId) + : turnPromptId(turnInput.attemptId), + "weak", + ), ordinal: turnInput.providerTurnOrdinal, status: "running", startedAt, @@ -2661,12 +2931,20 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* client.session.switchModel({ sessionID: Session.ID.make(sessionId), model }); state.model = model; } + // Steers a stopped turn never delivered would reach the model first, + // and a revert a failed rollback left staged would be committed. + yield* cancelStrandedSteers(state); + if (stagedReverts.has(sessionId)) yield* clearRevert(sessionId); const turn = yield* begin; // An execution OpenCode is running on its own takes this prompt at // its next step, so this turn is that execution from here on. yield* lock.withPermit(takeRunningWake(state)); yield* client.session - .prompt({ sessionID: Session.ID.make(sessionId), text: prompt(turnInput) }) + .prompt({ + sessionID: Session.ID.make(sessionId), + id: turnPromptId(turnInput.attemptId), + text: prompt(turnInput), + }) .pipe( // Deleted outside T3: the thread is broken, and forgetting it makes // the next turn resume, fail, and recreate it with a handoff. @@ -2713,11 +2991,60 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), ), steerTurn: (steerInput) => - Effect.fail( - new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ - driver, - providerThreadId: steerInput.providerThread.id, - }), + Effect.gen(function* () { + const sessionId = yield* sessionIdOf(steerInput.providerThread); + const state = threads.get(sessionId); + const turn = state?.active; + if ( + state === undefined || + turn === undefined || + turn.providerTurn.id !== steerInput.providerTurnId || + turn.interrupted + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `OpenCode turn ${steerInput.providerTurnId} is not active`, + }); + } + const inboxID = steerPromptId(steerInput.message.messageId); + // A retried steer that already reached the model is not sent again. + if (turn.settledInbox.has(inboxID)) return; + turn.steers.add(inboxID); + state.strandedSteers.delete(inboxID); + yield* client.session + .prompt({ + sessionID: Session.ID.make(sessionId), + id: inboxID, + text: providerMessageTextWithAttachmentPaths({ + text: steerInput.message.text, + attachments: steerInput.message.attachments, + attachmentsDir: serverConfig.attachmentsDir, + }).trim(), + delivery: "steer", + }) + .pipe( + Effect.tapError((cause) => + lock.withPermit( + Effect.gen(function* () { + turn.steers.delete(inboxID); + // Without a clear refusal OpenCode may hold it; the next prompt takes it back. + if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.strandedSteers.add(inboxID); + if (state.active === turn) yield* endIfSettled(state, turn); + }), + ), + ), + ); + }).pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterSteerRunError({ + driver, + providerThreadId: steerInput.providerThread.id, + providerTurnId: steerInput.providerTurnId, + cause, + }), + ), ), interruptTurn: (interruptInput) => Effect.gen(function* () { @@ -2752,6 +3079,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr state.unsettled = true; return yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); } + // Between the executions a late steer spans nothing runs yet. The + // steers are taken back, and a run they already started is skipped. + if (!reply.value.interrupted && state.active === turn && turn.heldEnd !== undefined) { + state.unsettled = true; + yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); + return yield* cancelStrandedSteers(state); + } // Nothing was running. Unless the execution already ended (its event // is on the way), the turn is still open and nothing stopped. if (!reply.value.interrupted && state.active === turn) { @@ -2886,56 +3220,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr readThreadSnapshot: ({ providerThread }) => Effect.gen(function* () { const sessionId = yield* sessionIdOf(providerThread); - const history = yield* paginate( - { sessionID: Session.ID.make(sessionId), order: "asc" as const, limit: 100 }, - client.message.list, - ).pipe(Stream.runCollect); - const snapshotAt = yield* DateTime.now; - const messages = history.flatMap((message): Array => { - const text = - message.type === "user" - ? message.text - : message.type === "assistant" - ? textOf(message.content) - : ""; - if (text.length === 0 || (message.type !== "user" && message.type !== "assistant")) { - return []; - } - const createdAt = message.time.created; - return [ - { - createdBy: message.type === "user" ? "user" : "agent", - creationSource: "provider", - id: idAllocator.derive.messageFromProviderItem({ - driver, - nativeItemId: message.id, - }), - threadId: providerThread.appThreadId ?? input.threadId, - runId: null, - nodeId: null, - role: message.type, - text, - attachments: [], - streaming: false, - createdAt, - updatedAt: createdAt, - }, - ]; - }); - const lastUser = history.findLast((message) => message.type === "user")?.id; - const state = threads.get(sessionId); - return { - providerThread: { - ...providerThread, - providerSessionId: input.providerSessionId, - nativeConversationHeadRef: lastUser === undefined ? null : ref(lastUser, "weak"), - status: "idle" as const, - updatedAt: snapshotAt, - }, - providerTurns: state === undefined ? [] : [...state.providerTurns.values()], - messages, - runtimeRequests: [], - }; + return yield* snapshotOf(providerThread, sessionId); }).pipe( Effect.mapError((cause) => isProviderAdapterError(cause) @@ -2948,21 +3233,110 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), ), rollbackThread: (rollbackInput) => - Effect.fail( - new ProviderAdapter.ProviderAdapterRollbackThreadError({ - driver, - providerThreadId: rollbackInput.providerThread.id, - checkpointId: rollbackInput.target.checkpointId, - cause: notYet("rollback"), - }), + Effect.gen(function* () { + const { providerThread, target } = rollbackInput; + const sessionId = yield* sessionIdOf(providerThread); + const state = threads.get(sessionId); + if (state?.active !== undefined || (state !== undefined && hasBackground(state))) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `Cannot roll back OpenCode session ${sessionId} while it is still working`, + }); + } + // A session this runtime has not loaded yet is loaded the way a turn + // would load it, which stops anything it still waits on. + if (state === undefined) yield* runtime.resumeThread({ providerThread }); + const boundary = yield* boundaryAfter( + rollbackInput.providerThreadTurns, + providerThread.id, + target.type === "provider_turn" ? target.providerTurn : undefined, + yield* userMessages(sessionId), + ); + if (boundary !== null) { + yield* rollBackTo(sessionId, SessionMessage.ID.make(boundary)); + } + // The snapshot's head is the last user message OpenCode kept. + return yield* snapshotOf(providerThread, sessionId); + }).pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause, + }), + ), ), forkThread: (forkInput) => - Effect.fail( - new ProviderAdapter.ProviderAdapterForkThreadError({ + Effect.gen(function* () { + const source = forkInput.sourceProviderThread; + const sourceId = yield* sessionIdOf(source); + const selected = + forkInput.providerTurnId === undefined + ? undefined + : forkInput.sourceProviderTurns?.find((turn) => turn.id === forkInput.providerTurnId); + if (forkInput.providerTurnId !== undefined && selected === undefined) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`, + }); + } + const before = + selected === undefined + ? null + : yield* boundaryAfter( + forkInput.sourceProviderTurns ?? [], + source.id, + selected, + yield* userMessages(sourceId), + ); + // A fork copies the source's history, model, location and rules; its + // message ids are new, so it has no turns T3 could cut at yet. + const forked = yield* client.session.fork({ + sessionID: Session.ID.make(sourceId), + ...(before === null ? {} : { before: SessionMessage.ID.make(before) }), + }); + const createdAt = yield* DateTime.now; + const providerThread: OrchestrationV2ProviderThread = { + id: idAllocator.derive.providerThread({ driver, nativeThreadId: forked.id }), driver, - providerThreadId: forkInput.sourceProviderThread.id, - cause: notYet("fork"), - }), + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: forkInput.targetThreadId, + ownerNodeId: forkInput.ownerNodeId ?? null, + nativeThreadRef: ref(forked.id), + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: { + providerThreadId: source.id, + ...(forkInput.providerTurnId === undefined + ? {} + : { providerTurnId: forkInput.providerTurnId }), + }, + createdAt, + updatedAt: createdAt, + }; + // A fork keeps the source's location, which is the target thread's + // worktree too: a fork inherits it. A later move reaches it on resume. + const state = register(providerThread, forked, forked.location.directory); + // The fork runs the target thread's mode, not whatever the source ran. + yield* writeRules(state, forkInput.runtimePolicy ?? state.policy); + return providerThread; + }).pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterForkThreadError({ + driver, + providerThreadId: forkInput.sourceProviderThread.id, + cause, + }), + ), ), }; return runtime; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index 74e4e8948306..f16a38d35ae9 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -119,7 +119,12 @@ function materializeMessageIds(value: unknown, messageIds: ReadonlyMap [ key, - typeof entry === "string" && (key === "id" || key === "messageID" || key === "parentID") + typeof entry === "string" && + (key === "id" || + key === "messageID" || + key === "parentID" || + key === "inboxID" || + key === "before") ? (messageIds.get(entry) ?? entry) : materializeMessageIds(entry, messageIds), ]), @@ -149,12 +154,16 @@ export class OpenCodeReplayController { const actualFrame = frameRecord(actual); const messageIds = new Map(this.messageIds); let conflictingMessageId = false; + // A message id the client picks (1.x prompts, 2.x steers) replaces the + // recorded one everywhere after it, so replies and events carry the live id. + const idKey = expectedFrame?.type === "session.promptAsync" ? "messageID" : "id"; if ( - expectedFrame?.type === "session.promptAsync" && + (expectedFrame?.type === "session.promptAsync" || + expectedFrame?.type === "session.prompt") && actualFrame?.type === expectedFrame.type ) { - const recordedId = frameRecord(expectedFrame.input)?.messageID; - const actualId = frameRecord(actualFrame.input)?.messageID; + const recordedId = frameRecord(expectedFrame.input)?.[idKey]; + const actualId = frameRecord(actualFrame.input)?.[idKey]; if ( typeof recordedId === "string" && typeof actualId === "string" && @@ -233,7 +242,23 @@ export class OpenCodeReplayController { } } - async *events(signal?: AbortSignal): AsyncIterable { + /** + * Like {@link untilEventsDelivered}, but also waits for recorded responses to + * be taken: over HTTP a request can start while an earlier one's answer is + * still in flight, and must not be matched against that answer. + */ + async untilInboundDelivered(): Promise { + while (true) { + this.throwFailure(); + if (this.transcript.entries[this.cursor]?.type !== "emit_inbound") return; + await this.changed(); + } + } + + async *events( + signal?: AbortSignal, + beforeEmit?: (label: string | undefined) => Promise, + ): AsyncIterable { while (true) { if (signal?.aborted === true) return; this.throwFailure(); @@ -241,6 +266,8 @@ export class OpenCodeReplayController { if (entry?.type === "emit_inbound") { const frame = frameRecord(entry.frame); if (frame?.type === "sdk.event") { + // A scenario can hold an event until it has done what happened meanwhile. + if (beforeEmit !== undefined) await beforeEmit(entry.label); if (entry.afterMs !== undefined && entry.afterMs > 0) { await Effect.runPromise(Effect.sleep(Duration.millis(entry.afterMs))); } diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 569e92dd2be4..6e96463b663b 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -16,7 +16,9 @@ import { type RuntimeMode, ThreadId, } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { OPENCODE2_HTTP_PROTOCOL, @@ -27,8 +29,12 @@ import { provideDeterministicTestRuntime } from "./testkit/DeterministicRuntime. import type { OrchestratorV2ScenarioStep } from "./testkit/OrchestratorScenario.ts"; import { runOrchestratorV2ProviderReplayScenario } from "./testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; +import { readProviderReplayTranscript } from "./testkit/ReplayTranscriptNdjson.ts"; +import * as IdAllocator from "./IdAllocator.ts"; const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; +/** Held until the scenario releases it, so the turn is still running meanwhile. */ +const FIRST_TURN_END = "first-turn-end"; const instanceId = ProviderInstanceId.make("opencode"); const bigPickle: ModelSelection = { instanceId, model: "opencode/big-pickle" }; const mimo: ModelSelection = { instanceId, model: "opencode/mimo-v2.6-flash-free" }; @@ -48,6 +54,8 @@ const event = (type: string, data: Record): ProviderReplayEntry event: { id: `evt_${type.replaceAll(".", "")}`, created: 1, type, data }, }, }); +const labelled = (entry: ProviderReplayEntry, label: string): ProviderReplayEntry => + entry.type === "runtime_exit" ? entry : { ...entry, label }; const T3_RULES = [{ action: "*", resource: "*", effect: "allow" }]; /** Paths the build and plan agents allow for themselves, as 2.0.18 lists them. */ const BUILD_PATHS = [ @@ -511,4 +519,154 @@ describe("OpenCode 2 through the orchestrator", () => { ); }).pipe(Effect.scoped), ); + + it.effect("forks from an earlier turn before the next turn's user message", () => + Effect.gen(function* () { + const name = "opencode2-fork"; + const cwd = yield* checkpointWorkspace(name); + const recorded = yield* readProviderReplayTranscript( + new URL("./testkit/fixtures/opencode2_fork/opencode_transcript.ndjson", import.meta.url), + ); + const transcript = yield* OpenCode2OrchestratorReplayHarness.decodeTranscript(recorded); + const source = threadCommands({ name, worktreePath: cwd }); + const target = ThreadId.make(`thread:${name}:target`); + const [one, two] = [source.message("one"), source.message("two")]; + const commands: ReadonlyArray = [ + source.create, + one, + two, + { + type: "thread.fork", + createdBy: "user", + creationSource: "web", + commandId: source.command("fork"), + sourceThreadId: source.threadId, + targetThreadId: target, + sourcePoint: { + type: "run", + runId: (yield* IdAllocator.IdAllocatorV2).derive.run({ + threadId: source.threadId, + ordinal: 1, + }), + }, + }, + { + ...source.message("repeat"), + threadId: target, + messageId: MessageId.make(`message:${name}:repeat`), + }, + ]; + const steps: Array = commands.flatMap((command) => [ + { type: "dispatch" as const, command }, + { type: "advance_clock" as const, duration: "1 millis" as const }, + ...(command.type === "message.dispatch" + ? [{ type: "await_thread_idle" as const, threadId: command.threadId }] + : []), + ]); + const result = yield* runOrchestratorV2ProviderReplayScenario( + { name, transcript, commands, steps, projectionThreadIds: [source.threadId, target] }, + OpenCode2OrchestratorReplayHarness, + ).pipe(provideDeterministicTestRuntime); + const forked = result.projections.get(target); + assert.isDefined(forked); + assert.equal(forked.contextTransfers[0]?.resolution?.strategy, "native_fork"); + assert.equal( + forked.providerThreads[0]?.nativeThreadRef?.nativeId, + recorded.metadata?.["forkedNativeSessionId"], + ); + // The fork keeps the first turn and drops the second: the model answers + // from the first alone, and T3 shows the inherited turn but not the other. + assert.deepEqual( + forked.runs.map((run) => run.status), + ["completed"], + ); + const visible = forked.visibleTurnItems.flatMap((row) => + row.item.type === "assistant_message" ? [row.item.text] : [], + ); + assert.deepEqual(visible, ["ONE", "ONE"]); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), + ); + + it.effect("never sends OpenCode a queued message that was cancelled", () => + Effect.gen(function* () { + const name = "opencode2-queued-cancel"; + const cwd = yield* checkpointWorkspace(name); + const thread = threadCommands({ name, worktreePath: cwd }); + const queued = (key: string): OrchestrationV2Command => ({ + ...thread.message(key), + dispatchMode: { type: "queue_after_active" }, + }); + const ids = yield* IdAllocator.IdAllocatorV2; + const run = (ordinal: number) => ids.derive.run({ threadId: thread.threadId, ordinal }); + // The first turn is held open until the queue has been changed: its end + // is the last event, after the cancel. + const [first, second, third] = [thread.message("first"), queued("second"), queued("third")]; + const cancel: OrchestrationV2Command = { + type: "queued-run.cancel", + commandId: thread.command("cancel"), + threadId: thread.threadId, + runId: run(2), + }; + const transcript = yield* OpenCode2OrchestratorReplayHarness.decodeTranscript({ + provider: OPENCODE_PROVIDER, + protocol: OPENCODE2_HTTP_PROTOCOL, + version: "2.0.18", + scenario: name, + entries: [ + ...createdSession(cwd), + out("session.prompt", { sessionID: SESSION, id: "", text: "" }), + reply("session.prompt", { + data: { + id: "msg_user_FIRST", + sessionID: SESSION, + time: { created: 1 }, + type: "user", + payload: { text: "" }, + delivery: "steer", + }, + }), + // Only the third message reaches OpenCode, as the next turn. + ...answeredPrompt("THIRD"), + ], + }); + const steps: Array = [ + { type: "dispatch", command: thread.create }, + { type: "advance_clock", duration: "1 millis" }, + { type: "dispatch", command: first, await: false, key: "first" }, + { type: "await_run_steerable", threadId: thread.threadId, runId: run(1) }, + { type: "dispatch", command: second }, + { type: "dispatch", command: third }, + { type: "dispatch", command: cancel }, + { type: "await_run_status", threadId: thread.threadId, runId: run(2), status: "cancelled" }, + { type: "release_replay_gate", label: FIRST_TURN_END }, + { type: "await", key: "first" }, + { type: "await_thread_idle", threadId: thread.threadId }, + ]; + const result = yield* runOrchestratorV2ProviderReplayScenario( + { + name, + transcript: { + ...transcript, + entries: [ + ...transcript.entries.slice(0, -4), + labelled( + event("session.execution.succeeded", { sessionID: SESSION }), + FIRST_TURN_END, + ), + ...transcript.entries.slice(-4), + ], + }, + commands: [thread.create, first, second, third, cancel], + steps, + }, + OpenCode2OrchestratorReplayHarness, + ).pipe(provideDeterministicTestRuntime); + const projection = result.projections.get(thread.threadId); + assert.isDefined(projection); + assert.deepEqual( + projection.runs.map((candidate) => candidate.status), + ["completed", "cancelled", "completed"], + ); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), + ); }); diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index fd09da7b069d..6173f170bf2d 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -3,7 +3,8 @@ * driver probes the binary, spawns `opencode serve`, and routes to the 2.x * adapter. One thread reads a file, runs a shell command, and stops another; * a Supervised thread approves one command, declines another, and answers a - * question; a plan-mode thread may write only its plan. + * question; a plan-mode thread may write only its plan; and a thread steers, + * queues, forks and rolls back. * * OPENCODE2_BIN=/path/to/opencode OPENCODE2_LIVE_ROOT=/scratch/dir \ * vp test run src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -22,6 +23,7 @@ import { EnvironmentId, MessageId, type ModelSelection, + type OrchestrationV2Command, type OrchestrationV2ThreadProjection, ProjectId, ProviderDriverKind, @@ -34,6 +36,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { FetchHttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { describe } from "vite-plus/test"; import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts"; @@ -627,4 +630,213 @@ describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchest }).pipe(Effect.provide(Layer.merge(liveLayer, NodeServices.layer)), Effect.scoped), 480_000, ); + + it.live( + "steers a running turn, queues two messages, forks an earlier turn and rolls back a write", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); + const orchestrator = yield* Orchestrator.OrchestratorV2; + // A worktree of its own, so rolling back restores files for this thread only. + const work = path.join(ROOT, "inbox-work"); + yield* fs.makeDirectory(work, { recursive: true }); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const git = (...args: ReadonlyArray) => + spawner.exitCode(ChildProcess.make("git", ["-C", work, ...args])); + yield* git("init", "-q"); + yield* git( + "-c", + "user.name=t3", + "-c", + "user.email=t3@example.com", + "commit", + "-q", + "--allow-empty", + "-m", + "init", + ); + const threadId = ThreadId.make("thread:opencode2-live-inbox"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:opencode2-live-inbox:create"), + threadId, + projectId: ProjectId.make("project:opencode2-live-inbox"), + title: "OpenCode 2 live inbox", + modelSelection: MODEL, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: work, + }); + const dispatch = ( + key: string, + text: string, + dispatchMode: Extract< + OrchestrationV2Command, + { type: "message.dispatch" } + >["dispatchMode"], + ) => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:opencode2-live-inbox:${key}`), + threadId, + messageId: MessageId.make(`message:opencode2-live-inbox:${key}`), + text, + attachments: [], + modelSelection: MODEL, + dispatchMode, + }); + const assistantText = (projection: OrchestrationV2ThreadProjection, runId: string) => + projection.turnItems + .flatMap((item) => + item.type === "assistant_message" && item.runId === runId ? [item.text] : [], + ) + .join("\n"); + + // 1. A turn that writes ALPHA, which the rollback keeps. + yield* dispatch( + "alpha", + "Use the write tool to create reverted.txt containing exactly ALPHA, then reply DONE.", + { type: "start_immediately" }, + ); + const first = yield* waitFor(threadId, settled); + assert.equal(first.runs[0]?.status, "completed"); + assert.equal((yield* fs.readFileString(path.join(work, "reverted.txt"))).trim(), "ALPHA"); + + // 2. A long shell turn: steered while it runs, with two messages queued behind it. + yield* dispatch( + "shell", + "Run the shell command `sleep 15 && echo A` with the shell tool in the foreground (not in the background) and wait for it, then use the write tool to overwrite reverted.txt with exactly BETA, then reply DONE_A.", + { type: "start_immediately" }, + ); + const running = yield* waitFor(threadId, (projection) => + projection.turnItems.some( + (item) => item.type === "command_execution" && item.status === "running", + ), + ); + const shellRun = running.runs.at(-1)!; + yield* dispatch("steer", "Also include the word STEERED in your final reply.", { + type: "steer_active", + targetRunId: shellRun.id, + }); + yield* dispatch("queued-b", "Reply with exactly QUEUED_B.", { type: "queue_after_active" }); + yield* dispatch("queued-c", "Reply with exactly QUEUED_C.", { type: "queue_after_active" }); + const drained = yield* waitFor( + threadId, + (projection) => projection.runs.length === 4 && settled(projection), + ); + const [, shell, queuedB, queuedC] = drained.runs; + assert.deepEqual( + drained.runs.map((run) => run.status), + ["completed", "completed", "completed", "completed"], + ); + // The steer joined the running turn: its answer is in that run, one provider turn each. + assert.include(assistantText(drained, shell!.id), "STEERED"); + assert.include(assistantText(drained, queuedB!.id), "QUEUED_B"); + assert.include(assistantText(drained, queuedC!.id), "QUEUED_C"); + assert.lengthOf(drained.providerTurns, 4); + // Steered natively: a steer user item in the shell run, no restart attempt. + assert.deepEqual( + drained.turnItems.flatMap((item) => + item.type === "user_message" && item.runId === shell!.id ? [item.inputIntent] : [], + ), + ["turn_start", "steer"], + ); + assert.lengthOf( + drained.attempts.filter((attempt) => attempt.runId === shell!.id), + 1, + ); + assert.equal((yield* fs.readFileString(path.join(work, "reverted.txt"))).trim(), "BETA"); + + // 3. Fork from the first turn: the fork only knows what came before the shell turn. + const forkId = ThreadId.make("thread:opencode2-live-inbox-fork"); + yield* orchestrator.dispatch({ + type: "thread.fork", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:opencode2-live-inbox:fork"), + sourceThreadId: threadId, + targetThreadId: forkId, + sourcePoint: { type: "run", runId: first.runs[0]!.id }, + }); + // In its own worktree, so the source's rollback below restores files for + // the source alone. The fork's session moves there with it. + const forkWork = path.join(ROOT, "inbox-fork-work"); + yield* fs.makeDirectory(forkWork, { recursive: true }); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("command:opencode2-live-inbox:fork-worktree"), + threadId: forkId, + worktreePath: forkWork, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:opencode2-live-inbox:fork-ask"), + threadId: forkId, + messageId: MessageId.make("message:opencode2-live-inbox:fork-ask"), + text: "Did I ask you to reply with QUEUED_B in this conversation? Answer only YES or NO.", + attachments: [], + modelSelection: MODEL, + dispatchMode: { type: "start_immediately" }, + }); + const forked = yield* waitFor(forkId, settled); + assert.equal(forked.contextTransfers[0]?.resolution?.strategy, "native_fork"); + assert.equal(forked.runs[0]?.status, "completed"); + assert.include(assistantText(forked, forked.runs[0]!.id).toUpperCase(), "NO"); + + // 4. Roll the source back to the first turn: OpenCode's history drops the + // later turns and T3's checkpoint puts ALPHA back. + const checkpoint = drained.checkpoints.find( + (candidate) => candidate.appRunOrdinal === 1 && candidate.status === "ready", + ); + assert.isDefined(checkpoint); + yield* orchestrator.dispatch({ + type: "checkpoint.rollback", + commandId: CommandId.make("command:opencode2-live-inbox:rollback"), + threadId, + scopeId: checkpoint!.scopeId, + checkpointId: checkpoint!.id, + }); + const rolledBack = yield* waitFor(threadId, (projection) => + projection.runs.slice(1).every((run) => run.status === "rolled_back"), + ); + assert.deepEqual( + rolledBack.runs.map((run) => run.status), + ["completed", "rolled_back", "rolled_back", "rolled_back"], + ); + assert.equal((yield* fs.readFileString(path.join(work, "reverted.txt"))).trim(), "ALPHA"); + yield* dispatch( + "after", + "Did I ask you to reply with QUEUED_B in this conversation? Answer only YES or NO.", + { type: "start_immediately" }, + ); + const after = yield* waitFor( + threadId, + (projection) => projection.runs.length === 5 && settled(projection), + ); + assert.include(assistantText(after, after.runs[4]!.id).toUpperCase(), "NO"); + // OpenCode's own history kept only the first turn before the new one. + const sessionId = after.providerThreads[0]?.nativeThreadRef?.nativeId; + const db = new NodeSqlite.DatabaseSync(`${ROOT}/data/opencode/opencode.db`, { + readOnly: true, + }); + const userMessages = db + .prepare( + "SELECT id FROM session_message WHERE session_id = ? AND type = 'user' ORDER BY seq", + ) + .all(sessionId!) + .map((row) => String(row.id)); + db.close(); + assert.lengthOf(userMessages, 2); + }).pipe(Effect.provide(Layer.merge(liveLayer, NodeServices.layer)), Effect.scoped), + 600_000, + ); }); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index 9936dc827176..f096585179eb 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -55,6 +55,10 @@ import { assertOpenCodeChildApprovalOutput } from "./opencode_child_approval/out import { openCodeRunningChildApprovalInput } from "./opencode_running_child_approval/input.ts"; import { assertOpenCodeRunningChildApprovalOutput } from "./opencode_running_child_approval/output.ts"; import { openCodeSubagentInput } from "./opencode_subagent/input.ts"; +import { openCode2InboxInput } from "./opencode2_inbox/input.ts"; +import { openCode2RevertInput } from "./opencode2_revert/input.ts"; +import { assertOpenCode2RevertOutput } from "./opencode2_revert/output.ts"; +import { assertOpenCode2InboxOutput } from "./opencode2_inbox/output.ts"; import { openCode2InterruptInput } from "./opencode2_interrupt/input.ts"; import { assertOpenCode2InterruptOutput } from "./opencode2_interrupt/output.ts"; import { openCode2PermissionInput } from "./opencode2_permission/input.ts"; @@ -824,6 +828,36 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray"}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} +{"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1573001elLn2XpVURsxOW", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f1484ea8affe5aTo8CcgbGIszj", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657107336, "updated": 1790657107336}, "title": "spike fork", "location": {"directory": ""}}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1588001REWGPFiBf7rtLq", "created": 1790657107336, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "slug": "playful-garden", "version": "2.0.18", "projectID": "global", "location": {"directory": ""}, "subpath": "", "title": "spike fork", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "id": "msg_0eb7b1597001e14VBV3nmQYdFS", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7b1597001e14VBV3nmQYdFS", "sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "time": {"created": 1790657107353}, "type": "user", "payload": {"text": "Reply exactly ONE."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1599001jGsE2bT343I3x4", "created": 1790657107353, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7b1597001e14VBV3nmQYdFS", "sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "item": {"type": "user", "payload": {"text": "Reply exactly ONE."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b15a0001LpMr36rJC5Ma1z", "created": 1790657107360, "type": "session.execution.started", "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj"}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b15ae001C75l93h2y3qriD", "created": 1790657107374, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "delta": {"core/environment": "db4a793a05fec32fdfa985a3432125a86d6cb42debc8b9ee8f6a99fbfcf2f93b", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b15b2001sUzT3mFUSgczVE", "created": 1790657107378, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "inboxID": "msg_0eb7b1597001e14VBV3nmQYdFS"}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1c4e001EK0efs2DwQWRaJ", "created": 1790657109070, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7b15bd001jhJdGY3CooZ6bF", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657107434}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1c52001w6CiHH67Dqdh92", "created": 1790657109074, "type": "session.reasoning.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b15bd001jhJdGY3CooZ6bF", "ordinal": 0, "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1cb9001uT2YwldMnnKp0p", "created": 1790657109177, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b15bd001jhJdGY3CooZ6bF", "ordinal": 0, "delta": "The user said"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1db900191s4acV5kqVJNg", "created": 1790657109433, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b15bd001jhJdGY3CooZ6bF", "ordinal": 0, "delta": " \"Reply exactly ONE.\" That's ambiguous. I should ask for clarification or interpret. \"Reply exactly ONE.\" \u2014 perhaps they want me to reply with exactly one word? Or \"ONE"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1ea1001nKjRuSWd6TPPxl", "created": 1790657109665, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b15bd001jhJdGY3CooZ6bF", "ordinal": 0, "delta": "\"?\n\nGiven ambiguity, the best action is to ask a clarifying question. But the instruction says \"Reply exactly ONE.\" Maybe the literal interpretation: reply with exactly \"ONE\".\n\n"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1f7c001qH7lNfOGaCk8NG", "created": 1790657109884, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b15bd001jhJdGY3CooZ6bF", "ordinal": 0, "delta": "Hmm. This seems like a test. The most literal compliant reply: \"ONE\". I'll reply with exactly that."}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1f7e001VL1QGiEbQOf2X2", "created": 1790657109886, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b15bd001jhJdGY3CooZ6bF", "ordinal": 0}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1f83001oSlyVpMtV8Jr3n", "created": 1790657109891, "type": "session.reasoning.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b15bd001jhJdGY3CooZ6bF", "ordinal": 0, "text": "The user said \"Reply exactly ONE.\" That's ambiguous. I should ask for clarification or interpret. \"Reply exactly ONE.\" \u2014 perhaps they want me to reply with exactly one word? Or \"ONE\"?\n\nGiven ambiguity, the best action is to ask a clarifying question. But the instruction says \"Reply exactly ONE.\" Maybe the literal interpretation: reply with exactly \"ONE\".\n\nHmm. This seems like a test. The most literal compliant reply: \"ONE\". I'll reply with exactly that.", "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1f86001vYS6UxQcSyaFeD", "created": 1790657109894, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b15bd001jhJdGY3CooZ6bF", "ordinal": 0, "delta": "ONE"}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1f86002BnDg59GyEeSwYY", "created": 1790657109894, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b15bd001jhJdGY3CooZ6bF", "ordinal": 0, "text": "ONE"}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1f88001kmJpx6zpzCEYbs", "created": 1790657109896, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b15bd001jhJdGY3CooZ6bF"}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 10, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1f9300152Zy2pY8fImosP", "created": 1790657109907, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b15bd001jhJdGY3CooZ6bF", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 8743, "output": 1, "reasoning": 99, "cache": {"read": 486, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1f970013QgCQfYeQ7OUht", "created": 1790657109911, "type": "session.usage.updated", "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "cost": 0, "tokens": {"input": 8743, "output": 1, "reasoning": 99, "cache": {"read": 486, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1f98001Yop5aIISQknu4b", "created": 1790657109912, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj"}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 12, "version": 1}}}} +{"type": "expect_outbound", "label": "session.prompt.2", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "id": "msg_0eb7b1f9f001g95Bduh3bi8syS", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response.2", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7b1f9f001g95Bduh3bi8syS", "sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "time": {"created": 1790657109921}, "type": "user", "payload": {"text": "Reply exactly TWO."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1fa1001tG16bwxTM8x8sI", "created": 1790657109921, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7b1f9f001g95Bduh3bi8syS", "sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "item": {"type": "user", "payload": {"text": "Reply exactly TWO."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 13, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1fa30010uR2gu0xMo3LZ8", "created": 1790657109923, "type": "session.execution.started", "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj"}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 14, "version": 1}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1fac001YEES1aqdXowIIg", "created": 1790657109932, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "inboxID": "msg_0eb7b1f9f001g95Bduh3bi8syS"}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 15, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b244a001lvU1308uYg6EiE", "created": 1790657111114, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7b1fb30011jHuDOQ5YiY6eH", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657109967}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 16, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b244e0019zdIrQbfOVf8KJ", "created": 1790657111118, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b1fb30011jHuDOQ5YiY6eH", "ordinal": 0}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 17, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b2452001PeauphkrisLSpJ", "created": 1790657111122, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b1fb30011jHuDOQ5YiY6eH", "ordinal": 0, "delta": "TWO"}}}} +{"type": "emit_inbound", "label": "session.text.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b2453001MHTwLE27jBtwEe", "created": 1790657111123, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b1fb30011jHuDOQ5YiY6eH", "ordinal": 0, "text": "TWO"}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 18, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b2456001hlVWC61YHu9q8w", "created": 1790657111126, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b1fb30011jHuDOQ5YiY6eH"}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 19, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b2461001qEeIO2dKdf1y1Y", "created": 1790657111137, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "assistantMessageID": "msg_0eb7b1fb30011jHuDOQ5YiY6eH", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 16, "output": 3, "reasoning": 0, "cache": {"read": 9327, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 20, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b2465001TtO12Ofsv2KZTq", "created": 1790657111141, "type": "session.usage.updated", "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "cost": 0, "tokens": {"input": 8759, "output": 4, "reasoning": 99, "cache": {"read": 9813, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b24660011TVz6Q64I8aAQ7", "created": 1790657111142, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj"}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 21, "version": 1}}}} +{"type": "expect_outbound", "label": "message.list", "frame": {"type": "message.list", "input": ""}} +{"type": "emit_inbound", "label": "message.list.response", "frame": {"type": "sdk.response", "operation": "message.list", "data": {"data": [{"id": "msg_0eb7b1f9f001g95Bduh3bi8syS", "time": {"created": 1790657109932}, "text": "Reply exactly TWO.", "type": "user"}, {"id": "msg_0eb7b1597001e14VBV3nmQYdFS", "time": {"created": 1790657107378}, "text": "Reply exactly ONE.", "type": "user"}], "cursor": {"next": "cursor-after-first-page"}}}} +{"type": "expect_outbound", "label": "message.list.2", "frame": {"type": "message.list", "input": ""}} +{"type": "emit_inbound", "label": "message.list.response.2", "frame": {"type": "sdk.response", "operation": "message.list", "data": {"data": [], "cursor": {}}}} +{"type": "expect_outbound", "label": "session.fork", "frame": {"type": "session.fork", "input": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "before": "msg_0eb7b1f9f001g95Bduh3bi8syS"}}} +{"type": "emit_inbound", "label": "session.fork.response", "frame": {"type": "sdk.response", "operation": "session.fork", "data": {"data": {"id": "ses_f1484db83ffeLGtrRCFimo1H0e", "fork": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "boundary": {"type": "before", "messageID": "msg_0eb7b1f9f001g95Bduh3bi8syS"}}, "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657111165, "updated": 1790657111165}, "title": "spike fork (fork #1)", "location": {"directory": ""}, "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "subagent", "resource": "*", "effect": "deny"}]}}}} +{"type": "expect_outbound", "label": "session.update", "frame": {"type": "session.update", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}]}}} +{"type": "emit_inbound", "label": "session.update.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}} +{"type": "emit_inbound", "label": "session.forked", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b247d001fXzFroPg5P0bps", "created": 1790657111165, "type": "session.forked", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "parentID": "ses_f1484ea8affe5aTo8CcgbGIszj", "boundary": {"type": "before", "messageID": "msg_0eb7b1f9f001g95Bduh3bi8syS"}, "instructions": {"core/environment": "db4a793a05fec32fdfa985a3432125a86d6cb42debc8b9ee8f6a99fbfcf2f93b", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}, "instructionEntries": []}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 0, "version": 2}}}} +{"type": "expect_outbound", "label": "session.prompt.3", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "id": "msg_0eb7b2493001nZTYCniIjU5Y65", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response.3", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7b2493001nZTYCniIjU5Y65", "sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "time": {"created": 1790657111188}, "type": "user", "payload": {"text": "What did you reply last time? Answer in one word."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b2494001M14VU7KlwYgdaF", "created": 1790657111188, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7b2493001nZTYCniIjU5Y65", "sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "item": {"type": "user", "payload": {"text": "What did you reply last time? Answer in one word."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 13, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b24980012x45ddwzLkKWuo", "created": 1790657111192, "type": "session.execution.started", "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e"}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 14, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b24a4001EdKExzDj017Inr", "created": 1790657111205, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "delta": {"core/environment": "b5c1a996156092a9ec966aca13f5a749f148103cc9103c60617eecda7d5a9b35"}, "text": "The environment you are running in is now:\n\n Current conversation session ID: ses_f1484db83ffeLGtrRCFimo1H0e\n Working directory: \n Workspace root folder: \n Is directory a git repo: yes\n Platform: linux\n Prefer /.cache/tmp-v2work/opencode over generic system temporary directories such as /tmp; it is pre-created and approved for external access.\n"}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 15, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b24a8001m9Qon0kQg1Fkub", "created": 1790657111208, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "inboxID": "msg_0eb7b2493001nZTYCniIjU5Y65"}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 16, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b284e001aMX7ayaf8UcVzR", "created": 1790657112142, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7b24b0001Wp15mmrlgFe2F1", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657111244}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 17, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b2852001CrgXtgZTR5fCoJ", "created": 1790657112146, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "assistantMessageID": "msg_0eb7b24b0001Wp15mmrlgFe2F1", "ordinal": 0}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 18, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b28550011ISN9JjRR5MxG1", "created": 1790657112149, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "assistantMessageID": "msg_0eb7b24b0001Wp15mmrlgFe2F1", "ordinal": 0, "delta": "ONE"}}}} +{"type": "emit_inbound", "label": "session.text.ended.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b2856001a9XY2dlMF3e2x0", "created": 1790657112150, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "assistantMessageID": "msg_0eb7b24b0001Wp15mmrlgFe2F1", "ordinal": 0, "text": "ONE"}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 19, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b2859001nkRvI0Wi6YKUly", "created": 1790657112153, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "assistantMessageID": "msg_0eb7b24b0001Wp15mmrlgFe2F1"}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 20, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b2865001UrC36a4WfrJxou", "created": 1790657112165, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "assistantMessageID": "msg_0eb7b24b0001Wp15mmrlgFe2F1", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 160, "output": 2, "reasoning": 0, "cache": {"read": 9332, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 21, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b28690017Tk00oW1mCcGmr", "created": 1790657112169, "type": "session.usage.updated", "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "cost": 0, "tokens": {"input": 160, "output": 2, "reasoning": 0, "cache": {"read": 9332, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b286a001F5qilJ2Gxm37L1", "created": 1790657112170, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e"}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 22, "version": 1}}}} +{"type": "runtime_exit", "status": "success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/input.ts new file mode 100644 index 000000000000..2e30b84ab530 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/input.ts @@ -0,0 +1,24 @@ +import { + OPENCODE2_CANCELLED_PROMPT, + OPENCODE2_QUEUED_PROMPT, + OPENCODE2_STEER_PROMPT, + OPENCODE2_STEER_TEXT, + type OrchestratorFixtureInput, +} from "../shared.ts"; + +/** + * While the recorded shell call runs, two messages are queued (the second is + * cancelled) and one is steered in. OpenCode reads the steer at the call's + * step boundary; the queued message then runs as its own turn. + */ +export function openCode2InboxInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_STEER_PROMPT }, + { type: "queue_message", text: OPENCODE2_QUEUED_PROMPT }, + { type: "queue_message", text: OPENCODE2_CANCELLED_PROMPT }, + { type: "cancel_queued_run", targetRunIndex: 3 }, + { type: "steer", text: OPENCODE2_STEER_TEXT, targetRunIndex: 1 }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/opencode_transcript.ndjson new file mode 100644 index 000000000000..e0365d7ca75a --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/opencode_transcript.ndjson @@ -0,0 +1,51 @@ +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_inbox", "metadata": {"source": "opencode2-spike/recordings/inbox.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f14854c71ffeuPLxKSK70AHWvd", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: a steer lands in a running shell turn at its next step boundary. The recording also queued two prompts natively; T3 queues them itself, so those requests are dropped and the first queued message runs as its own turn, in the execution OpenCode ran it in, after the steered turn ends. The steer is sent while the shell call runs, as T3 can only steer a turn it sees running."}} +{"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} +{"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": ""}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} +{"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ab38c001npTkjN5FpjHmXO", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f14854c71ffeuPLxKSK70AHWvd", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657082272, "updated": 1790657082272}, "title": "spike inbox", "location": {"directory": ""}}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ab3a0001MfhwH3aV04StYO", "created": 1790657082272, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "slug": "lucky-mountain", "version": "2.0.18", "projectID": "global", "location": {"directory": ""}, "subpath": "", "title": "spike inbox", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "id": "msg_0eb7ab3b9001sMV4nmgEXFKFfw", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7ab3b9001sMV4nmgEXFKFfw", "sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "time": {"created": 1790657082299}, "type": "user", "payload": {"text": "Run the shell command `sleep 12 && echo A` with the bash tool, then reply DONE_A."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ab3bb001AZRfHxE6SeDXCc", "created": 1790657082299, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7ab3b9001sMV4nmgEXFKFfw", "sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "item": {"type": "user", "payload": {"text": "Run the shell command `sleep 12 && echo A` with the bash tool, then reply DONE_A."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ab3c0001ZH0wHoXFdD6D5f", "created": 1790657082304, "type": "session.execution.started", "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd"}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ab3cc001P0ZBA28qwSFQWQ", "created": 1790657082316, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "delta": {"core/environment": "25fd616e41fb11ca958b7118d08c04376f8ebe0ff856fd79c1e6544701063c17", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ab3cf001IktB3Qa5QDiajB", "created": 1790657082319, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "inboxID": "msg_0eb7ab3b9001sMV4nmgEXFKFfw"}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aba49001Gye3zsIqceCRyg", "created": 1790657083978, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7ab3d7001OI20u116rCTC67", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657082357}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aba4d001R1X4TdZ8PDqwPl", "created": 1790657083981, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7ab3d7001OI20u116rCTC67", "id": "call_function_uzsss3vvsylr_1", "name": "shell"}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 10, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7abaf20014XnY5J7On53fSK", "created": 1790657084146, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7ab3d7001OI20u116rCTC67", "id": "call_function_uzsss3vvsylr_1", "text": "{\"command\": \"sleep 12 && echo A\", \"timeout\": 30000}"}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7abaf5001CR4UtCzVrtiVm2", "created": 1790657084149, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7ab3d7001OI20u116rCTC67", "id": "call_function_uzsss3vvsylr_1", "input": {"command": "sleep 12 && echo A", "timeout": 30000}, "executed": false}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 12, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7abaf9001E87Z33Wllo7pVc", "created": 1790657084153, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7ab3d7001OI20u116rCTC67"}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 13, "version": 1}}}} +{"type": "emit_inbound", "label": "shell.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7abb03001OXOvANFqFoRveu", "created": 1790657084163, "type": "shell.created", "location": {"directory": ""}, "data": {"info": {"id": "sh_0eb7abb00001t11VzP02Pv9sjv", "status": "running", "command": "sleep 12 && echo A", "cwd": "", "shell": "/bin/bash", "file": "/xdg-data/opencode/shell/global/sh_0eb7abb00001t11VzP02Pv9sjv.out", "metadata": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd"}, "time": {"started": 1790657084160}}}}}} +{"type": "emit_inbound", "label": "session.tool.progress", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7abb040019h3010dI73kz7H", "created": 1790657084164, "type": "session.tool.progress", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7ab3d7001OI20u116rCTC67", "id": "call_function_uzsss3vvsylr_1", "metadata": {"shellID": "sh_0eb7abb00001t11VzP02Pv9sjv"}}}}} +{"type": "expect_outbound", "label": "session.prompt.2", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "id": "msg_0eb7ab3f0001ECNoIc3JkrrTSL", "text": "", "delivery": "steer"}}} +{"type": "emit_inbound", "label": "session.prompt.response.2", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7ab3f0001ECNoIc3JkrrTSL", "sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "time": {"created": 1790657082353}, "type": "user", "payload": {"text": "Also mention the word STEERED in your final reply."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ab3f10012dXAe3vlwO7PZi", "created": 1790657082353, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7ab3f0001ECNoIc3JkrrTSL", "sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "item": {"type": "user", "payload": {"text": "Also mention the word STEERED in your final reply."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "shell.exited", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ae9e9001it1OPfNm6njdjT", "created": 1790657096169, "type": "shell.exited", "location": {"directory": ""}, "data": {"id": "sh_0eb7abb00001t11VzP02Pv9sjv", "exit": 0, "status": "exited"}}}} +{"type": "emit_inbound", "label": "session.tool.success", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ae9ec001eRdrPhEkHRVf3i", "created": 1790657096172, "type": "session.tool.success", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7ab3d7001OI20u116rCTC67", "id": "call_function_uzsss3vvsylr_1", "content": [{"type": "text", "text": "A\n"}], "metadata": {"status": "completed", "truncated": false, "exit": 0}, "executed": false}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 14, "version": 2}}}} +{"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ae9fd001x26jm09WjQEUWQ", "created": 1790657096189, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7ab3d7001OI20u116rCTC67", "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 8804, "output": 41, "reasoning": 0, "cache": {"read": 443, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 15, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aea01001Y2A6FQ8Z1vUFaq", "created": 1790657096193, "type": "session.usage.updated", "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "cost": 0, "tokens": {"input": 8804, "output": 41, "reasoning": 0, "cache": {"read": 443, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aea0a001vgzVpYr3hB3fif", "created": 1790657096203, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "inboxID": "msg_0eb7ab3f0001ECNoIc3JkrrTSL"}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 16, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aed38001lcCXF6t2g5c1bb", "created": 1790657097016, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7aea13001p1Cn2XIxcO7dWg", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657096239}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 17, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aed3b001qwjIpqz1eclcyZ", "created": 1790657097019, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7aea13001p1Cn2XIxcO7dWg", "ordinal": 0}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 18, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aed67001FO1wwNtIRb2p9T", "created": 1790657097064, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7aea13001p1Cn2XIxcO7dWg", "ordinal": 0, "delta": "DONE_A\n\nSTEERED"}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aed69001iXndT0Fcvf937U", "created": 1790657097065, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7aea13001p1Cn2XIxcO7dWg", "ordinal": 0, "text": "DONE_A\n\nSTEERED"}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 19, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aed6c001Li0g3Ot9ULiUIV", "created": 1790657097068, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7aea13001p1Cn2XIxcO7dWg"}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 20, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aed7c001Frg5jvD9VzxB6R", "created": 1790657097084, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7aea13001p1Cn2XIxcO7dWg", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 36, "output": 8, "reasoning": 0, "cache": {"read": 9286, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 21, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aed80001WNoWYFvUdmHH6n", "created": 1790657097088, "type": "session.usage.updated", "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "cost": 0, "tokens": {"input": 8840, "output": 49, "reasoning": 0, "cache": {"read": 9729, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aed80002steeredTurnEnd", "created": 1790657098035, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd"}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 21, "version": 1}}}} +{"type": "expect_outbound", "label": "session.prompt.3", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "id": "msg_0eb7ab3fb001qtnGUqjSWYG85e", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response.3", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7ab3fb001qtnGUqjSWYG85e", "sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "time": {"created": 1790657082364}, "type": "user", "payload": {"text": "Reply exactly QUEUED_B."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aed84001queuedTurnStart", "created": 1790657097098, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7ab3fb001qtnGUqjSWYG85e", "sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "item": {"type": "user", "payload": {"text": "Reply exactly QUEUED_B."}, "delivery": "steer"}}}}} +{"type": "emit_inbound", "label": "session.execution.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aed85001queuedTurnStart", "created": 1790657097098, "type": "session.execution.started", "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd"}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7aed8a001klzh31YovCyIYz", "created": 1790657097098, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "inboxID": "msg_0eb7ab3fb001qtnGUqjSWYG85e"}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 22, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7af103001maInZp4w8jj7SU", "created": 1790657097987, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7aed92001xelMrvIDqDO4Rn", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657097129}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 23, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7af1070017pVheJJMAj4nwD", "created": 1790657097991, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7aed92001xelMrvIDqDO4Rn", "ordinal": 0}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 24, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7af10c001cUesjWJytCBrYN", "created": 1790657097996, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7aed92001xelMrvIDqDO4Rn", "ordinal": 0, "delta": "QUEUED_B"}}}} +{"type": "emit_inbound", "label": "session.text.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7af10d0011J0kLkfR2HJzm6", "created": 1790657097997, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7aed92001xelMrvIDqDO4Rn", "ordinal": 0, "text": "QUEUED_B"}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 25, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7af110001IHfiNU3jGJxT1q", "created": 1790657098000, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7aed92001xelMrvIDqDO4Rn"}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 26, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7af12c0010ylkrjSCCna61F", "created": 1790657098028, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "assistantMessageID": "msg_0eb7aed92001xelMrvIDqDO4Rn", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 18, "output": 4, "reasoning": 0, "cache": {"read": 9328, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 27, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7af130001NLcfLRZPD1BHoB", "created": 1790657098032, "type": "session.usage.updated", "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "cost": 0, "tokens": {"input": 8858, "output": 53, "reasoning": 0, "cache": {"read": 19057, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7af1330013Uos4dNjaBzTgU", "created": 1790657098035, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd"}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 28, "version": 1}}}} +{"type": "runtime_exit", "status": "success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/output.ts new file mode 100644 index 000000000000..3d396585e6c2 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/output.ts @@ -0,0 +1,72 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertUserMessageInputIntents, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_CANCELLED_PROMPT, + OPENCODE2_QUEUED_PROMPT, + OPENCODE2_STEER_PROMPT, + OPENCODE2_STEER_TEXT, + projectionFor, +} from "../shared.ts"; + +/** + * The steer goes out as `delivery: "steer"` into the running turn, which the + * shell call's step boundary delivers: one provider turn answers both, and it + * ends with its execution. T3 keeps the queue, so the queued message is a + * second turn and the cancelled one never reaches OpenCode. + */ +export function assertOpenCode2InboxOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ + result, + transcript, + runCount: 3, + // The cancelled run never started a provider turn. + providerTurnCountAtLeast: 2, + runStatuses: ["completed", "completed", "cancelled"], + }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertUserMessagesInclude(projection, [ + OPENCODE2_STEER_PROMPT, + OPENCODE2_STEER_TEXT, + OPENCODE2_QUEUED_PROMPT, + ]); + assertUserMessageInputIntents(projection, ["turn_start", "steer", "queued_turn"]); + assertAssistantTextIncludes(projection, "STEERED"); + assertAssistantTextIncludes(projection, "QUEUED_B"); + assert.notInclude( + projection.turnItems.flatMap((item) => (item.type === "user_message" ? [item.text] : [])), + OPENCODE2_CANCELLED_PROMPT, + ); + + // The steer joined the first run's turn; the queued message got its own. + const [first, queued] = projection.runs.toSorted((left, right) => left.ordinal - right.ordinal); + const turnOf = (run: typeof first) => + projection.providerTurns.filter((turn) => turn.runAttemptId === run?.activeAttemptId); + assert.deepEqual( + [turnOf(first).map((turn) => turn.status), turnOf(queued).map((turn) => turn.status)], + [["completed"], ["completed"]], + ); + const steered = projection.turnItems.find( + (item) => item.type === "assistant_message" && item.text.includes("STEERED"), + ); + assert.equal(steered?.runId, first?.id); + const shell = projection.turnItems.find((item) => item.type === "command_execution"); + assert.deepEqual([shell?.status, shell?.runId], ["completed", first?.id]); + + // Each turn remembers the user message it prompted with: fork and rollback cut there. + for (const turn of [...turnOf(first), ...turnOf(queued)]) { + assert.match(turn.nativeTurnRef?.nativeId ?? "", /^msg_t3_turn_/); + } +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/input.ts new file mode 100644 index 000000000000..64761e211751 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/input.ts @@ -0,0 +1,17 @@ +import { + OPENCODE2_REVERT_FIRST_PROMPT, + OPENCODE2_REVERT_SECOND_PROMPT, + type OrchestratorFixtureInput, +} from "../shared.ts"; + +/** Two turns that write the same file, then a rollback to the first. */ +export function openCode2RevertInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_REVERT_FIRST_PROMPT }, + { type: "message", text: OPENCODE2_REVERT_SECOND_PROMPT }, + { type: "rollback", checkpointScopeSuffix: "root", checkpointSuffix: "1" }, + { type: "await_run_status", targetRunIndex: 2, status: "rolled_back" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/opencode_transcript.ndjson new file mode 100644 index 000000000000..652c0c7a9644 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/opencode_transcript.ndjson @@ -0,0 +1,66 @@ +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_revert", "metadata": {"source": "opencode2-spike/recordings/revert.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: two turns write reverted.txt, then the thread rolls back to the first. Stages send files: false because T3 restores its own checkpoint."}} +{"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} +{"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": ""}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} +{"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b410d001mJ5lqjFqXTbW7y", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657118493, "updated": 1790657118493}, "title": "spike revert", "location": {"directory": ""}}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b411d0014yaniNS8WiN6zE", "created": 1790657118493, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "slug": "witty-canyon", "version": "2.0.18", "projectID": "global", "location": {"directory": ""}, "subpath": "", "title": "spike revert", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "id": "msg_0eb7b4129001B7iaT1f3yLkR28", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7b4129001B7iaT1f3yLkR28", "sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "time": {"created": 1790657118506}, "type": "user", "payload": {"text": "Create a file named reverted.txt containing the word ALPHA using the write tool, then reply DONE."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b412a0017VZ2v8p3ah9908", "created": 1790657118506, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7b4129001B7iaT1f3yLkR28", "sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "item": {"type": "user", "payload": {"text": "Create a file named reverted.txt containing the word ALPHA using the write tool, then reply DONE."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b412e001bOpRdBrQkbSVMp", "created": 1790657118510, "type": "session.execution.started", "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b413a001xqoWzREtM1bxSc", "created": 1790657118522, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "delta": {"core/environment": "1fb93b6d2754d1e11df271151626ea29363330ef925a66431e89b8f87f4f66a8", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b413e001iF0HkIl4131lx9", "created": 1790657118526, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "inboxID": "msg_0eb7b4129001B7iaT1f3yLkR28"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4621001PpfIT7TbTMOC3o", "created": 1790657119777, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7b4146001Y63G7t2Pk1IpJJ", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790657118562}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4629001vDeYhyfdppCTJY", "created": 1790657119785, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4146001Y63G7t2Pk1IpJJ", "id": "call-146cafaa-f178-4ab4-8008-a118b7518a98", "name": "write"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4676001RW4QaJDxBLgumo", "created": 1790657119862, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4146001Y63G7t2Pk1IpJJ", "id": "call-146cafaa-f178-4ab4-8008-a118b7518a98", "text": "{\"path\":\"reverted.txt\",\"content\":\"ALPHA\\n\"}"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4679001aZnw5iwUvLu09y", "created": 1790657119865, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4146001Y63G7t2Pk1IpJJ", "id": "call-146cafaa-f178-4ab4-8008-a118b7518a98", "input": {"path": "reverted.txt", "content": "ALPHA\n"}, "executed": false}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b467b001zeIGHvSO3fjAqH", "created": 1790657119868, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4146001Y63G7t2Pk1IpJJ"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.success", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4683001gOY1icnL1L3QxE", "created": 1790657119875, "type": "session.tool.success", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4146001Y63G7t2Pk1IpJJ", "id": "call-146cafaa-f178-4ab4-8008-a118b7518a98", "content": [{"type": "text", "text": "Created file successfully: reverted.txt"}], "metadata": {"truncated": false}, "executed": false}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 10, "version": 2}}}} +{"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b46a2001FMOJ74ERzgyvWH", "created": 1790657119906, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4146001Y63G7t2Pk1IpJJ", "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 8801, "output": 40, "reasoning": 0, "cache": {"read": 384, "write": 0}}, "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": ["reverted.txt"]}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b46a6001FqCcMFVD4Kn6x4", "created": 1790657119910, "type": "session.usage.updated", "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "cost": 0, "tokens": {"input": 8801, "output": 40, "reasoning": 0, "cache": {"read": 384, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.step.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4b41001DXqoodCWWUDDiE", "created": 1790657121089, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7b46b2001GnMZuEFelqdw4Z", "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "started": 1790657119945}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 12, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4b4c001A8H90thE772xBC", "created": 1790657121100, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b46b2001GnMZuEFelqdw4Z", "ordinal": 0}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 13, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4b54001RcZuNFSEwZvbQ3", "created": 1790657121108, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b46b2001GnMZuEFelqdw4Z", "ordinal": 0, "delta": "DONE"}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4b57001RNxUsI8vWN99K3", "created": 1790657121111, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b46b2001GnMZuEFelqdw4Z", "ordinal": 0, "text": "DONE"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 14, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4b5a001w4hYVv4N6AhV6y", "created": 1790657121114, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b46b2001GnMZuEFelqdw4Z"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 15, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4b6c001vwxl0D9IdpNj5l", "created": 1790657121132, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b46b2001GnMZuEFelqdw4Z", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 30, "output": 4, "reasoning": 0, "cache": {"read": 9216, "write": 0}}, "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": []}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 16, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4b700016FlQ47T6vT5wWI", "created": 1790657121136, "type": "session.usage.updated", "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "cost": 0, "tokens": {"input": 8831, "output": 44, "reasoning": 0, "cache": {"read": 9600, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4b71001ZeYwxZ7kxCeClt", "created": 1790657121137, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 17, "version": 1}}}} +{"type": "expect_outbound", "label": "session.prompt.2", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "id": "msg_0eb7b4b79001y4nhrY7WvzIH4b", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response.2", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7b4b79001y4nhrY7WvzIH4b", "sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "time": {"created": 1790657121146}, "type": "user", "payload": {"text": "Overwrite reverted.txt so it contains the word BETA using the write tool, then reply DONE."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4b7a001uuAbLVOFQgFoH7", "created": 1790657121146, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7b4b79001y4nhrY7WvzIH4b", "sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "item": {"type": "user", "payload": {"text": "Overwrite reverted.txt so it contains the word BETA using the write tool, then reply DONE."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 18, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4b7d001eTYSFJQzpplVYf", "created": 1790657121149, "type": "session.execution.started", "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 19, "version": 1}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4b88001uSD2xopMciXtS6", "created": 1790657121160, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "inboxID": "msg_0eb7b4b79001y4nhrY7WvzIH4b"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 20, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4f01001YSV3rEN2SIp55c", "created": 1790657122049, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7b4b900014IYY7YZyY6DkUm", "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "started": 1790657121195}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 21, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4f05001pj1eM1uKGLRukC", "created": 1790657122053, "type": "session.tool.input.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4b900014IYY7YZyY6DkUm", "id": "call-4a391b60-0f17-48d3-afa1-b79f4004cae7", "name": "write"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 22, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4f78001MRq0Ce0HBh764R", "created": 1790657122168, "type": "session.tool.input.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4b900014IYY7YZyY6DkUm", "id": "call-4a391b60-0f17-48d3-afa1-b79f4004cae7", "text": "{\"path\":\"reverted.txt\",\"content\":\"BETA\\n\"}"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 23, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4f7b001qvxLxuY6b2Ewut", "created": 1790657122171, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4b900014IYY7YZyY6DkUm", "id": "call-4a391b60-0f17-48d3-afa1-b79f4004cae7", "input": {"path": "reverted.txt", "content": "BETA\n"}, "executed": false}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 24, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4f7e001pcuDJAVYr6JFcp", "created": 1790657122174, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4b900014IYY7YZyY6DkUm"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 25, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.success.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4f86001FL7hn8Or8Kifw7", "created": 1790657122182, "type": "session.tool.success", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4b900014IYY7YZyY6DkUm", "id": "call-4a391b60-0f17-48d3-afa1-b79f4004cae7", "content": [{"type": "text", "text": "Wrote file successfully: reverted.txt"}], "metadata": {"truncated": false}, "executed": false}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 26, "version": 2}}}} +{"type": "emit_inbound", "label": "session.step.ended.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4fae001xhtOoTn0bZSP8W", "created": 1790657122222, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4b900014IYY7YZyY6DkUm", "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 63, "output": 39, "reasoning": 0, "cache": {"read": 9216, "write": 0}}, "snapshot": "aff42fe121ca6ba7ff4cd8ff03b92c4653937c1e", "files": ["reverted.txt"]}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 27, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b4fb2001In0EV1zpiuDeBL", "created": 1790657122226, "type": "session.usage.updated", "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "cost": 0, "tokens": {"input": 8894, "output": 83, "reasoning": 0, "cache": {"read": 18816, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.step.started.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b52ff001Pnow2hevlx3iAp", "created": 1790657123072, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7b4fc1001jd3wUmiUPduL54", "snapshot": "aff42fe121ca6ba7ff4cd8ff03b92c4653937c1e", "started": 1790657122266}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 28, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b5303001n2LyUg2Lrzvp0c", "created": 1790657123075, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4fc1001jd3wUmiUPduL54", "ordinal": 0}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 29, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b5316001jENjxGDnzE35ec", "created": 1790657123094, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4fc1001jd3wUmiUPduL54", "ordinal": 0, "delta": "DONE"}}}} +{"type": "emit_inbound", "label": "session.text.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b5317001aZKstXQjBbe5xi", "created": 1790657123095, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4fc1001jd3wUmiUPduL54", "ordinal": 0, "text": "DONE"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 30, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b531a0011GicyMC5xAsJWy", "created": 1790657123098, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4fc1001jd3wUmiUPduL54"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 31, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b5328001InlyUFAKg7pkcR", "created": 1790657123112, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "assistantMessageID": "msg_0eb7b4fc1001jd3wUmiUPduL54", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 124, "output": 4, "reasoning": 0, "cache": {"read": 9216, "write": 0}}, "snapshot": "aff42fe121ca6ba7ff4cd8ff03b92c4653937c1e", "files": []}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 32, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b532c001iSqv9ja5oJJh5r", "created": 1790657123116, "type": "session.usage.updated", "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "cost": 0, "tokens": {"input": 9018, "output": 87, "reasoning": 0, "cache": {"read": 28032, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b532e001ZS3mBEHiZ7I7cn", "created": 1790657123118, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 33, "version": 1}}}} +{"type": "expect_outbound", "label": "message.list.users.1", "frame": {"type": "message.list", "input": ""}} +{"type": "emit_inbound", "label": "message.list.users.response.1", "frame": {"type": "sdk.response", "operation": "message.list", "data": {"data": [{"id": "msg_0eb7b4b79001y4nhrY7WvzIH4b", "time": {"created": 1790657121160}, "text": "Overwrite reverted.txt so it contains the word BETA using the write tool, then reply DONE.", "type": "user"}, {"id": "msg_0eb7b4129001B7iaT1f3yLkR28", "time": {"created": 1790657118526}, "text": "Create a file named reverted.txt containing the word ALPHA using the write tool, then reply DONE.", "type": "user"}], "cursor": {"next": "cursor-after-first-page"}}}} +{"type": "expect_outbound", "label": "message.list.users.1b", "frame": {"type": "message.list", "input": ""}} +{"type": "emit_inbound", "label": "message.list.users.response.1b", "frame": {"type": "sdk.response", "operation": "message.list", "data": {"data": [], "cursor": {}}}} +{"type": "expect_outbound", "label": "session.revert.stage.1", "frame": {"type": "session.revert.stage", "input": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "messageID": "msg_0eb7b4b79001y4nhrY7WvzIH4b", "files": false}}} +{"type": "emit_inbound", "label": "session.revert.stage.response.1", "frame": {"type": "sdk.response", "operation": "session.revert.stage", "data": {"data": {"messageID": "msg_0eb7b4b79001y4nhrY7WvzIH4b", "snapshot": "aff42fe121ca6ba7ff4cd8ff03b92c4653937c1e", "files": []}}}} +{"type": "emit_inbound", "label": "session.revert.staged", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b5373001KgvYcl3wXRr4ok", "created": 1790657123187, "type": "session.revert.staged", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "revert": {"messageID": "msg_0eb7b4b79001y4nhrY7WvzIH4b", "snapshot": "aff42fe121ca6ba7ff4cd8ff03b92c4653937c1e", "files": []}}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 34, "version": 1}}}} +{"type": "expect_outbound", "label": "session.revert.commit", "frame": {"type": "session.revert.commit", "input": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus"}}} +{"type": "emit_inbound", "label": "session.revert.committed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b53d8001i9FEBuTU2HtYwD", "created": 1790657123288, "type": "session.revert.committed", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "to": "msg_0eb7b4b79001y4nhrY7WvzIH4b"}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 39, "version": 1}}}} +{"type": "emit_inbound", "label": "session.revert.commit.response", "frame": {"type": "sdk.response", "operation": "session.revert.commit", "data": null}} +{"type": "expect_outbound", "label": "message.list.2", "frame": {"type": "message.list", "input": ""}} +{"type": "emit_inbound", "label": "message.list.response.2", "frame": {"type": "sdk.response", "operation": "message.list", "data": {"data": [{"id": "msg_0eb7b4129001B7iaT1f3yLkR28", "time": {"created": 1790657118526}, "text": "Create a file named reverted.txt containing the word ALPHA using the write tool, then reply DONE.", "type": "user"}, {"id": "msg_0eb7b4146001Y63G7t2Pk1IpJJ", "time": {"created": 1790657118562, "streamed": 1790657119868, "completed": 1790657119906}, "type": "assistant", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "content": [{"type": "tool", "id": "call-146cafaa-f178-4ab4-8008-a118b7518a98", "name": "write", "executed": false, "state": {"status": "completed", "input": {"path": "reverted.txt", "content": "ALPHA\n"}, "content": [{"type": "text", "text": "Created file successfully: reverted.txt"}], "metadata": {"truncated": false}}, "time": {"created": 1790657119785, "ran": 1790657119865, "completed": 1790657119875}}], "snapshot": {"start": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "end": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": ["reverted.txt"]}, "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 8801, "output": 40, "reasoning": 0, "cache": {"read": 384, "write": 0}}}, {"id": "msg_0eb7b46b2001GnMZuEFelqdw4Z", "time": {"created": 1790657119945, "streamed": 1790657121114, "completed": 1790657121132}, "type": "assistant", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "content": [{"type": "text", "text": "DONE"}], "snapshot": {"start": "d580eac455a946e5bef414d21d0abbfb32eabdae", "end": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": []}, "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 30, "output": 4, "reasoning": 0, "cache": {"read": 9216, "write": 0}}}, {"id": "msg_0eb7b4b71001ZeYwxZ7kxCeClt", "time": {"created": 1790657121137}, "type": "idle", "outcome": "succeeded"}], "cursor": {"previous": "eyJpZCI6Im1zZ18wZWI3YjQxMjkwMDFCN2lhVDFmM3lMa1IyOCIsIm9yZGVyIjoiYXNjIiwiZGlyZWN0aW9uIjoicHJldmlvdXMifQ", "next": "eyJpZCI6Im1zZ18wZWI3YjRiNzEwMDFaZVl3eFo3a3hDZUNsdCIsIm9yZGVyIjoiYXNjIiwiZGlyZWN0aW9uIjoibmV4dCJ9"}}}} +{"type": "expect_outbound", "label": "message.list.history.2", "frame": {"type": "message.list", "input": ""}} +{"type": "emit_inbound", "label": "message.list.history.response.2", "frame": {"type": "sdk.response", "operation": "message.list", "data": {"data": [], "cursor": {}}}} +{"type": "runtime_exit", "status": "success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/output.ts new file mode 100644 index 000000000000..8adfb401e9be --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/output.ts @@ -0,0 +1,39 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertVisibleUserMessagesExclude, + assertVisibleUserMessagesInclude, + OPENCODE2_REVERT_FIRST_PROMPT, + OPENCODE2_REVERT_SECOND_PROMPT, + projectionFor, +} from "../shared.ts"; + +/** + * The rollback stages a revert before the second turn's user message and + * commits it, so OpenCode's history ends with the first turn. + */ +export function assertOpenCode2RevertOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ + result, + transcript, + runCount: 2, + runStatuses: ["completed", "rolled_back"], + }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleUserMessagesInclude(projection, [OPENCODE2_REVERT_FIRST_PROMPT]); + assertVisibleUserMessagesExclude(projection, [OPENCODE2_REVERT_SECOND_PROMPT]); + // The provider thread's head is the kept turn's prompt, OpenCode's last user message. + const kept = projection.providerTurns.find((turn) => turn.ordinal === 1); + assert.equal( + projection.providerThreads[0]?.nativeConversationHeadRef?.nativeId, + kept?.nativeTurnRef?.nativeId, + ); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index a9f8393d2d39..4b358fa63a71 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -73,6 +73,15 @@ export const OPENCODE2_INTERRUPT_PROMPT = "Run the shell command `sleep 60 && echo LATE` with the bash tool, then reply DONE."; export const OPENCODE2_PERMISSION_PROMPT = "Run the shell command `echo FIRST` with the bash tool. After it completes, run `echo SECOND` with the bash tool. Then reply with what happened."; +export const OPENCODE2_STEER_PROMPT = + "Run the shell command `sleep 12 && echo A` with the bash tool, then reply DONE_A."; +export const OPENCODE2_STEER_TEXT = "Also mention the word STEERED in your final reply."; +export const OPENCODE2_QUEUED_PROMPT = "Reply exactly QUEUED_B."; +export const OPENCODE2_CANCELLED_PROMPT = "Reply exactly QUEUED_C."; +export const OPENCODE2_REVERT_FIRST_PROMPT = + "Create a file named reverted.txt containing the word ALPHA using the write tool, then reply DONE."; +export const OPENCODE2_REVERT_SECOND_PROMPT = + "Overwrite reverted.txt so it contains the word BETA using the write tool, then reply DONE."; export const OPENCODE2_QUESTION_PROMPT = "Before doing anything, use the question tool to ask me which color I prefer, offering the options red and blue. After I answer, reply with only the chosen color."; export const OPENCODE2_SUBAGENT_PROMPT = From 4e6fe5c494906fdec334ccfedd6ab55761870eb1 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:03:38 -0700 Subject: [PATCH 29/34] feat(server): OpenCode 2 compaction, plan mode and workspace inventory Carries pingdotgg/t3code#14374 oc2-8-compaction-switch unchanged, squashed to one commit with Julius Marminge's authorship. Drop this layer once it merges into codex-turn-mapping. --- .../Adapters/OpenCode2AdapterV2.testkit.ts | 11 +- .../Adapters/OpenCode2AdapterV2.ts | 248 ++++++++++++++---- ...penCode2OrchestratorV2.integration.test.ts | 81 +++++- .../OpenCode2OrchestratorV2.live.test.ts | 127 ++++++++- .../testkit/fixtures/index.ts | 45 ++++ .../fixtures/opencode2_command/input.ts | 5 + .../opencode_transcript.ndjson | 30 +++ .../fixtures/opencode2_command/output.ts | 34 +++ .../fixtures/opencode2_compaction/input.ts | 15 ++ .../opencode_transcript.ndjson | 64 +++++ .../fixtures/opencode2_compaction/output.ts | 61 +++++ .../testkit/fixtures/opencode2_skill/input.ts | 5 + .../opencode_transcript.ndjson | 30 +++ .../fixtures/opencode2_skill/output.ts | 34 +++ .../opencode_transcript.ndjson | 84 ++++++ .../testkit/fixtures/shared.ts | 4 + .../provider/Drivers/OpenCodeDriver.test.ts | 37 ++- .../src/provider/Drivers/OpenCodeDriver.ts | 65 ++++- .../provider/Layers/OpenCodeProvider.test.ts | 83 ++++++ .../src/provider/Layers/OpenCodeProvider.ts | 72 ++++- .../testFixtures/opencodeProbeResponses.ts | 27 +- 21 files changed, 1095 insertions(+), 67 deletions(-) create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/opencode_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/opencode_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/opencode_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index d9f7457f2e3d..5720d1375c48 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -66,14 +66,23 @@ const operationOf = ( if (method === "GET" && path === "/api/agent") return { type: "agent.list", input: query }; if (method === "POST" && path === "/api/session") return { type: "session.create", input: body }; if (method === "GET" && path === "/api/session/active") return { type: "session.active" }; + if (method === "GET" && path === "/api/command") return { type: "command.list", input: query }; + if (method === "GET" && path === "/api/skill") return { type: "skill.list", input: query }; if (session !== null) { const [, sessionID, rest = ""] = session; - const input = { sessionID, ...query, ...(body === undefined ? {} : (body as object)) }; + // The client sends absent optional fields as `null`; recordings omit them. + const fields = Object.entries((body ?? {}) as Record).filter( + ([, value]) => value !== null, + ); + const input = { sessionID, ...query, ...Object.fromEntries(fields) }; if (method === "GET" && rest === "") return { type: "session.get", input }; if (method === "PATCH" && rest === "") return { type: "session.update", input }; if (method === "POST" && rest === "/prompt") return { type: "session.prompt", input }; + if (method === "POST" && rest === "/command") return { type: "session.command", input }; + if (method === "POST" && rest === "/compact") return { type: "session.compact", input }; if (method === "POST" && rest === "/interrupt") return { type: "session.interrupt", input }; if (method === "POST" && rest === "/model") return { type: "session.switchModel", input }; + if (method === "POST" && rest === "/agent") return { type: "session.switchAgent", input }; if (method === "POST" && rest === "/move") return { type: "session.move", input }; if (method === "POST" && rest === "/synthetic") return { type: "session.synthetic", input }; if (method === "POST" && rest === "/fork") return { type: "session.fork", input }; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index 2ce951a4f036..f89ec8c46ee1 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -3,25 +3,27 @@ * instance's `opencode serve` process through the HTTP client and reads that * server's `/api/event` stream, routed here by session id. * - * A turn is one `session.prompt`; the session's next `session.execution.*` - * terminal ends it. A steer is another prompt with `delivery: "steer"`, which - * OpenCode reads at the running execution's next step boundary, so it ends - * with the turn it joined. Fork and rollback cut the native history before the - * user message a turn prompted with (its `nativeTurnRef`). Each runtime mode is - * a set of session permission rules, and OpenCode's permission asks and - * question forms become runtime requests on the asking session's thread, a + * A turn is one `session.prompt` (or `session.command`, or `session.compact` + * for `/compact`); the session's next `session.execution.*` terminal ends it. + * A steer is another prompt with `delivery: "steer"`, which OpenCode reads at + * the running execution's next step boundary, so it ends with the turn it + * joined. Fork and rollback cut the native history before the user message a + * turn prompted with (its `nativeTurnRef`). Each runtime mode is a set of + * session permission rules, plan mode is also OpenCode's `plan` agent + * (switched before the prompt like the model), and OpenCode's permission asks + * and question forms become runtime requests on the asking session's thread, a * subagent's on its parent's. * * A `subagent` call runs in a child session shown as a subagent thread. A * background one outlives its turn; when it ends, OpenCode starts a parent * execution T3 did not ask for, which waits here for the continuation turn T3 - * opens for it. Compaction arrives in a later layer, and the capabilities - * below say no. + * opens for it. * * @module orchestration-v2/Adapters/OpenCode2AdapterV2 */ import { AbsolutePath, + Agent, Form, Location, Model, @@ -29,6 +31,7 @@ import { Provider, Session, SessionMessage, + Skill, type OpenCodeEvent, } from "@opencode/client/effect"; import { @@ -70,6 +73,7 @@ import { type OpenCodeRuntimeError, } from "../../provider/opencodeRuntime.ts"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; +import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { causeErrorTag } from "@t3tools/shared/observability"; @@ -217,6 +221,9 @@ interface ActiveTurn { }; steps: number; lastStep: Tokens | undefined; + /** The compaction running in this turn, `/compact` or OpenCode's own when the context fills. */ + compaction: { readonly nativeId: string; readonly startedAt: DateTime.Utc } | undefined; + compactions: number; interrupted: boolean; /** * Steers sent into this turn that OpenCode has not delivered yet, by inbox @@ -303,7 +310,10 @@ interface ThreadState { unsettled: boolean; /** The session's location, where its agents' path rules are read. */ directory: string; - /** The agent the session runs; its own path rules stay in force. */ + /** + * The agent the session runs (`build`, or `plan` in plan mode); its own path + * rules stay in force, and a changed mode switches it before prompting. + */ agent: string; /** The native session's rules as T3 last read or wrote them, and the policy they are for. */ rules: ReadonlyArray | undefined; @@ -564,12 +574,16 @@ const isWakeTurn = (turn: OrchestrationV2ProviderTurn) => turn.nativeTurnRef?.nativeId?.includes(":wake:") === true; const INTERRUPT_TIMEOUT = "10 seconds"; +/** How long a turn waits on the directory's commands or skills before sending the text as is. */ +const INVENTORY_TIMEOUT = "5 seconds"; const ACTIVE_CHECK_TIMEOUT = "5 seconds"; /** Answers that mean the server refused a prompt; any other failure may have been accepted. */ const CLEAR_PROMPT_REJECTIONS: ReadonlySet = new Set([ "InvalidRequestError", "ConflictError", "UnauthorizedError", + "CommandNotFoundError", + "SkillNotFoundError", ]); export const OPENCODE_2_STILL_STOPPING = @@ -667,6 +681,28 @@ const sameModel = (left: ModelRef, right: ModelRef | undefined) => left.id === right?.id && (left.variant ?? "default") === (right?.variant ?? "default"); +/** OpenCode's own agents for T3's interaction modes; plan mode is its read-only `plan` agent. */ +const agentFor = (input: ProviderAdapter.ProviderAdapterV2TurnInput) => + input.runtimePolicy.interactionMode === "plan" ? "plan" : "build"; + +/** `/name args` naming one of the workspace's commands, which OpenCode expands itself. */ +const commandOf = (text: string) => { + const match = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(text.trim()); + return match === null ? undefined : { name: match[1]!, text: match[2] ?? "" }; +}; + +/** Whether a prompt names any skill at all, before the directory's skills are read. */ +const SKILL_MENTION = new RegExp(SKILL_MENTION_PATTERN.source, "u"); + +/** The workspace skills a prompt names with the composer's `$skill` tokens. */ +const skillsNamed = (text: string, known: ReadonlySet) => [ + ...new Set( + [...text.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => + known.has(match[2] ?? "") ? [match[2]!] : [], + ), + ), +]; + /** * The turn's own tokens: steps add up, and the last step's input is the live * context size. A subagent's tokens are its own session's, never these. @@ -992,6 +1028,47 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); }); + /** The turn's compaction item; its summary is the text OpenCode carries forward. */ + const emitCompaction = Effect.fnUntraced(function* ( + state: ThreadState, + turn: ActiveTurn, + status: "running" | "completed" | "failed" | "interrupted", + summary?: string, + ) { + const compaction = turn.compaction; + if (compaction === undefined) return; + const updatedAt = yield* DateTime.now; + const completedAt = status === "running" ? null : updatedAt; + yield* emit({ + type: "turn_item.updated", + driver, + turnItem: { + ...itemBase( + state, + turn, + compaction.nativeId, + status, + compaction.startedAt, + completedAt, + updatedAt, + ), + nodeId: turn.input.rootNodeId, + type: "compaction", + driver, + title: + status === "running" + ? "Compacting context" + : status === "completed" + ? "Context compacted" + : status === "failed" + ? "Compaction failed" + : "Compaction interrupted", + ...(summary === undefined || summary.length === 0 ? {} : { summary }), + }, + }); + if (status !== "running") turn.compaction = undefined; + }); + const emitProviderTurn = ( state: ThreadState, turn: ActiveTurn, @@ -1021,6 +1098,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0 }, steps: 0, lastStep: undefined, + compaction: undefined, + compactions: 0, interrupted: false, steers: new Set(), settledInbox: new Set(), @@ -1446,6 +1525,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }, }); } + yield* emitCompaction(state, turn, terminal.status === "failed" ? "failed" : "interrupted"); const window = contextWindows.get(turn.input.modelSelection.model); const lastStep = turn.lastStep; yield* emitProviderTurn(state, turn, { @@ -1940,6 +2020,18 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr turn.tools.delete(event.data.id); return; } + // `/compact` and OpenCode's own compaction when the context fills + // (`reason: "auto"`) both run inside a turn's execution. + case "session.compaction.started": + turn.compaction = { + nativeId: `${turn.providerTurn.id}:compaction:${turn.compactions++}`, + startedAt: yield* DateTime.now, + }; + return yield* emitCompaction(state, turn, "running"); + case "session.compaction.ended": + return yield* emitCompaction(state, turn, "completed", event.data.text); + case "session.compaction.failed": + return yield* emitCompaction(state, turn, "failed"); case "session.step.ended": case "session.step.failed": { const tokens = event.data.tokens; @@ -2574,7 +2666,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }; }); - const prompt = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => { + const promptText = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => { const text = providerMessageTextWithAttachmentPaths({ text: turnInput.message.text, attachments: turnInput.message.attachments, @@ -2732,6 +2824,58 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); }); + /** + * What a turn sends: `/compact` compacts, `/name args` naming a workspace + * command runs it, and anything else is a prompt with the `$skill`s it + * names attached. Commands and skills are read from the session's + * directory only when the text could use them. + */ + const submit = Effect.fnUntraced(function* ( + sessionId: string, + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + ) { + const sessionID = Session.ID.make(sessionId); + const text = turnInput.message.text.trim(); + const bare = turnInput.message.attachments.length === 0; + // The turn's own id, so fork and rollback cut before this turn's item. + const id = turnPromptId(turnInput.attemptId); + if (bare && text === "/compact") { + return yield* client.session.compact({ sessionID, id }).pipe(Effect.asVoid); + } + const location = { directory: turnInput.runtimePolicy.cwd ?? serverConfig.cwd }; + const command = bare ? commandOf(text) : undefined; + if (command !== undefined) { + const commands = yield* client.command.list({ location }).pipe( + Effect.timeout(INVENTORY_TIMEOUT), + Effect.map((list) => list.data), + Effect.orElseSucceed(() => []), + ); + if (commands.some((entry) => entry.name === command.name)) { + return yield* client.session.command({ sessionID, ...command }); + } + } + const skills = SKILL_MENTION.test(text) + ? skillsNamed( + text, + yield* client.skill.list({ location }).pipe( + Effect.timeout(INVENTORY_TIMEOUT), + Effect.map((list) => new Set(list.data.map((skill) => skill.id))), + Effect.orElseSucceed(() => new Set()), + ), + ) + : []; + return yield* client.session + .prompt({ + sessionID, + id, + text: promptText(turnInput), + ...(skills.length === 0 + ? {} + : { skills: skills.map((id) => ({ id: Skill.ID.make(id) })) }), + }) + .pipe(Effect.asVoid); + }); + const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId, driver, @@ -2861,6 +3005,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }), ), ), + // `/compact` is its own turn, which `startTurn` sends as `session.compact`. + compactThread: (turnInput) => + runtime.startTurn({ ...turnInput, message: { ...turnInput.message, text: "/compact" } }), startTurn: (turnInput) => Effect.gen(function* () { const sessionId = yield* sessionIdOf(turnInput.providerThread); @@ -2923,6 +3070,17 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }), }); } + // Plan mode is OpenCode's `plan` agent, switched before the rules are + // written so they keep that agent's own path allows. Switching to or + // from `plan` queues OpenCode's own "Plan mode" reminder for the prompt. + const agent = agentFor(turnInput); + if (agent !== state.agent) { + yield* client.session.switchAgent({ + sessionID: Session.ID.make(sessionId), + agent: Agent.ID.make(agent), + }); + state.agent = agent; + } // The thread's mode may have changed since the session was loaded. yield* writeRules(state, turnInput.runtimePolicy); // A selection changed since the last turn applies now; OpenCode keeps @@ -2939,44 +3097,38 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // An execution OpenCode is running on its own takes this prompt at // its next step, so this turn is that execution from here on. yield* lock.withPermit(takeRunningWake(state)); - yield* client.session - .prompt({ - sessionID: Session.ID.make(sessionId), - id: turnPromptId(turnInput.attemptId), - text: prompt(turnInput), - }) - .pipe( - // Deleted outside T3: the thread is broken, and forgetting it makes - // the next turn resume, fail, and recreate it with a handoff. - Effect.catchTags({ - SessionNotFoundError: () => - finishTurn( - state, - { + yield* submit(sessionId, turnInput).pipe( + // Deleted outside T3: the thread is broken, and forgetting it makes + // the next turn resume, fail, and recreate it with a handoff. + Effect.catchTags({ + SessionNotFoundError: () => + finishTurn( + state, + { + status: "failed", + failure: makeProviderFailure({ + message: + "The OpenCode session no longer exists. Send the message again to continue in a new session.", + class: "provider_error", + }), + }, + "broken", + ).pipe(Effect.andThen(Effect.sync(() => threads.delete(sessionId)))), + }), + Effect.tapError((cause) => + state.active === turn + ? Effect.gen(function* () { + // Without a clear rejection the server may have taken the + // prompt, so the next turn checks before it prompts again. + if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.unsettled = true; + yield* finishTurn(state, { status: "failed", - failure: makeProviderFailure({ - message: - "The OpenCode session no longer exists. Send the message again to continue in a new session.", - class: "provider_error", - }), - }, - "broken", - ).pipe(Effect.andThen(Effect.sync(() => threads.delete(sessionId)))), - }), - Effect.tapError((cause) => - state.active === turn - ? Effect.gen(function* () { - // Without a clear rejection the server may have taken the - // prompt, so the next turn checks before it prompts again. - if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.unsettled = true; - yield* finishTurn(state, { - status: "failed", - failure: makeProviderFailure({ cause, class: "provider_error" }), - }); - }) - : Effect.void, - ), - ); + failure: makeProviderFailure({ cause, class: "provider_error" }), + }); + }) + : Effect.void, + ), + ); }).pipe( Effect.mapError((cause) => isProviderAdapterError(cause) diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 6e96463b663b..57a6b3943df3 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -3,6 +3,7 @@ * the transcript fixes the order of every request the adapter sends, so a * request the orchestrator never lets it make fails the run. */ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { CommandId, @@ -16,9 +17,10 @@ import { type RuntimeMode, ThreadId, } from "@t3tools/contracts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { OPENCODE2_HTTP_PROTOCOL, @@ -29,7 +31,10 @@ import { provideDeterministicTestRuntime } from "./testkit/DeterministicRuntime. import type { OrchestratorV2ScenarioStep } from "./testkit/OrchestratorScenario.ts"; import { runOrchestratorV2ProviderReplayScenario } from "./testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; -import { readProviderReplayTranscript } from "./testkit/ReplayTranscriptNdjson.ts"; +import { + decodeProviderReplayNdjson, + readProviderReplayTranscript, +} from "./testkit/ReplayTranscriptNdjson.ts"; import * as IdAllocator from "./IdAllocator.ts"; const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; @@ -38,6 +43,7 @@ const FIRST_TURN_END = "first-turn-end"; const instanceId = ProviderInstanceId.make("opencode"); const bigPickle: ModelSelection = { instanceId, model: "opencode/big-pickle" }; const mimo: ModelSelection = { instanceId, model: "opencode/mimo-v2.6-flash-free" }; +const nemotron: ModelSelection = { instanceId, model: "opencode/nemotron-3.5-lightning-free" }; const out = (type: string, input?: unknown): ProviderReplayEntry => ({ type: "expect_outbound", @@ -219,7 +225,7 @@ const threadCommands = (input: { branch: null, worktreePath: input.worktreePath, } satisfies OrchestrationV2Command, - message: (key: string, modelSelection: ModelSelection = bigPickle) => + message: (key: string, modelSelection: ModelSelection = bigPickle, text?: string) => ({ type: "message.dispatch", createdBy: "user", @@ -227,11 +233,18 @@ const threadCommands = (input: { commandId: command(key), threadId, messageId: MessageId.make(`message:${input.name}:${key}`), - text: `Reply with exactly: ${key}`, + text: text ?? `Reply with exactly: ${key}`, attachments: [], modelSelection, dispatchMode: { type: "start_immediately" }, }) satisfies OrchestrationV2Command, + interactionMode: (key: string, interactionMode: "default" | "plan") => + ({ + type: "thread.interaction-mode.set", + commandId: command(key), + threadId, + interactionMode, + }) satisfies OrchestrationV2Command, command, }; }; @@ -494,11 +507,16 @@ describe("OpenCode 2 through the orchestrator", () => { threadId: thread.threadId, entries: [ ...createdSession(cwd, PLAN_RULES), + // Plan mode is also OpenCode's plan agent, switched before the prompt. + out("session.switchAgent", { sessionID: SESSION, agent: "plan" }), + reply("session.switchAgent", null), ...answeredPrompt("PLANNED"), out("session.get", { sessionID: SESSION }), reply("session.get", sessionInfo(cwd, PLAN_RULES)), out("session.update", { sessionID: SESSION, permissions: T3_RULES }), reply("session.update", null), + out("session.switchAgent", { sessionID: SESSION, agent: "build" }), + reply("session.switchAgent", null), ...answeredPrompt("BUILT"), ], commands: [ @@ -669,4 +687,59 @@ describe("OpenCode 2 through the orchestrator", () => { ); }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), ); + + it.effect( + "runs plan mode as OpenCode's plan agent and switches back before the next prompt", + () => + Effect.gen(function* () { + const name = "opencode2_switch"; + const cwd = yield* checkpointWorkspace(name); + const thread = threadCommands({ name, worktreePath: cwd }); + // The spike's recording: plan agent, then build agent and a new model on + // one session. Its `` is this test's workspace. + const recorded = yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const text = yield* fs.readFileString( + yield* path.fromFileUrl( + new URL( + "./testkit/fixtures/opencode2_switch/opencode_transcript.ndjson", + import.meta.url, + ), + ), + ); + return yield* decodeProviderReplayNdjson(text.replaceAll("", cwd)); + }).pipe(Effect.provide(NodeServices.layer)); + const projection = yield* runScenario({ + name, + threadId: thread.threadId, + entries: recorded.entries, + commands: [ + thread.create, + thread.interactionMode("mode-plan", "plan"), + thread.message( + "plan", + bigPickle, + "Create a file named plan_probe.txt containing HI using the write tool.", + ), + thread.interactionMode("mode-default", "default"), + thread.message("switched", nemotron, "Reply exactly SWITCHED."), + ], + }); + assert.deepEqual( + projection.runs.map((run) => [run.status, run.modelSelection.model]), + [ + ["completed", bigPickle.model], + ["completed", nemotron.model], + ], + ); + const replies = projection.turnItems.flatMap((item) => + item.type === "assistant_message" ? [item.text] : [], + ); + // The plan agent refused to write; OpenCode's own reminder told it why. + assert.include(replies[0], "Plan mode"); + assert.equal(replies[1], "SWITCHED"); + assert.lengthOf(projection.providerThreads, 1); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 6173f170bf2d..e66a7ce645ee 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -12,6 +12,7 @@ * The server runs with isolated HOME and XDG directories on the free * `opencode/big-pickle` model; `OPENCODE2_MODEL` picks another (its provider's * key comes from the test's environment, which the spawned server inherits). + * A second run covers plan mode, a workspace command and skill, and `/compact`. */ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -229,7 +230,10 @@ const send = Effect.fn("OpenCode2Live.send")(function* ( }); const AssistantModel = Schema.fromJsonString( - Schema.Struct({ model: Schema.Struct({ providerID: Schema.String, id: Schema.String }) }), + Schema.Struct({ + model: Schema.Struct({ providerID: Schema.String, id: Schema.String }), + agent: Schema.optional(Schema.String), + }), ); const decodeAssistantModel = Schema.decodeUnknownSync(AssistantModel); @@ -253,6 +257,22 @@ const assistantModels = (nativeSessionId: string) => } }); +/** The agent that wrote each assistant message in a native session, oldest first. */ +const assistantAgents = (nativeSessionId: string) => + Effect.sync(() => { + const db = new NodeSqlite.DatabaseSync(`${ROOT}/data/opencode/opencode.db`, { readOnly: true }); + try { + return db + .prepare( + "SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' ORDER BY seq", + ) + .all(nativeSessionId) + .map((row) => decodeAssistantModel(row.data).agent); + } finally { + db.close(); + } + }); + describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchestrator", () => { it.live( "runs a tool turn and stops a running shell command through the real driver", @@ -492,8 +512,8 @@ describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchest }); // The plan agent's directory: `$HOME/.opencode/plan` under the isolated HOME. const planDir = path.join(ROOT, ".opencode", "plan"); - // No agent switch yet (a later layer adds it), so the build agent runs - // under the session's plan rules: the edit deny holds on its own. + // The plan agent runs under the session's plan rules: the edit deny + // holds for the workspace, and the plan directory stays writable. yield* send( threadId, "plan", @@ -839,4 +859,105 @@ describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchest }).pipe(Effect.provide(Layer.merge(liveLayer, NodeServices.layer)), Effect.scoped), 600_000, ); + + it.live( + "runs plan mode, a workspace command and skill, and /compact through the real driver", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const work = path.join(ROOT, "work"); + yield* fs.makeDirectory(path.join(work, ".opencode", "command"), { recursive: true }); + yield* fs.makeDirectory(path.join(work, ".opencode", "skills", "greet"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(work, ".opencode", "command", "hello.md"), + "---\ndescription: Say hello to the workspace\n---\nReply with exactly: HELLO $ARGUMENTS\n", + ); + yield* fs.writeFileString( + path.join(work, ".opencode", "skills", "greet", "SKILL.md"), + "---\nname: greet\ndescription: Greets the user with the secret word MANGO.\n---\nWhen this skill is active, begin your reply with the exact word MANGO.\n", + ); + yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); + + // The workspace's own command and skill reach the composer's pickers. + const instance = + yield* (yield* ProviderInstanceRegistry.ProviderInstanceRegistry).getInstance(INSTANCE); + assert.isDefined(instance); + const workspace = yield* instance!.snapshotForCwd!(work); + assert.include( + workspace.skills.map((skill) => skill.name), + "greet", + ); + assert.includeMembers( + workspace.slashCommands.map((command) => command.name), + ["compact", "hello"], + ); + + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("thread:opencode2-live-modes"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:opencode2-live-modes:create"), + threadId, + projectId: ProjectId.make("project:opencode2-live-modes"), + title: "OpenCode 2 live modes", + modelSelection: MODEL, + runtimeMode: "full-access", + interactionMode: "plan", + branch: null, + worktreePath: work, + }); + const runs = (count: number) => (projection: OrchestrationV2ThreadProjection) => + projection.runs.length === count && settled(projection); + const lastReply = (projection: OrchestrationV2ThreadProjection) => + projection.turnItems.findLast((item) => item.type === "assistant_message"); + + // Plan mode is OpenCode's plan agent: it plans instead of editing. + yield* send( + threadId, + "modes-plan", + "Plan how to add a --verbose flag to a script named cli.js. Present a short plan; do not implement it.", + ); + const planned = yield* waitFor(threadId, runs(1)); + assert.equal(planned.runs[0]?.status, "completed"); + const sessionId = planned.providerThreads[0]?.nativeThreadRef?.nativeId; + assert.isDefined(sessionId); + const planSteps = yield* assistantAgents(sessionId!); + assert.isAbove(planSteps.length, 0); + assert.isTrue(planSteps.every((agent) => agent === "plan")); + + yield* orchestrator.dispatch({ + type: "thread.interaction-mode.set", + commandId: CommandId.make("command:opencode2-live-modes:default"), + threadId, + interactionMode: "default", + }); + yield* send(threadId, "modes-command", "/hello WORLD"); + const commanded = yield* waitFor(threadId, runs(2)); + assert.equal(commanded.runs[1]?.status, "completed"); + assert.include(lastReply(commanded)?.text ?? "", "HELLO WORLD"); + assert.equal((yield* assistantAgents(sessionId!)).at(-1), "build"); + + yield* send(threadId, "modes-skill", "Use $greet to say hi in three words."); + const skilled = yield* waitFor(threadId, runs(3)); + assert.equal(skilled.runs[2]?.status, "completed"); + assert.include(lastReply(skilled)?.text ?? "", "MANGO"); + + yield* send(threadId, "modes-compact", "/compact"); + const compacted = yield* waitFor(threadId, runs(4)); + assert.equal(compacted.runs[3]?.status, "completed"); + const compaction = compacted.turnItems.find((item) => item.type === "compaction"); + assert.equal(compaction?.status, "completed"); + assert.equal(compaction?.runId, compacted.runs[3]?.id); + assert.isAbove( + compaction?.type === "compaction" ? (compaction.summary ?? "").length : 0, + 0, + ); + }).pipe(Effect.provide(Layer.merge(liveLayer, NodeServices.layer)), Effect.scoped), + 360_000, + ); }); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index f096585179eb..148b90714b9a 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -59,6 +59,10 @@ import { openCode2InboxInput } from "./opencode2_inbox/input.ts"; import { openCode2RevertInput } from "./opencode2_revert/input.ts"; import { assertOpenCode2RevertOutput } from "./opencode2_revert/output.ts"; import { assertOpenCode2InboxOutput } from "./opencode2_inbox/output.ts"; +import { openCode2CommandInput } from "./opencode2_command/input.ts"; +import { assertOpenCode2CommandOutput } from "./opencode2_command/output.ts"; +import { openCode2CompactionInput } from "./opencode2_compaction/input.ts"; +import { assertOpenCode2CompactionOutput } from "./opencode2_compaction/output.ts"; import { openCode2InterruptInput } from "./opencode2_interrupt/input.ts"; import { assertOpenCode2InterruptOutput } from "./opencode2_interrupt/output.ts"; import { openCode2PermissionInput } from "./opencode2_permission/input.ts"; @@ -70,6 +74,8 @@ import { assertOpenCode2BackgroundOutput } from "./opencode2_background/output.t import { openCode2SubagentInput } from "./opencode2_subagent/input.ts"; import { assertOpenCode2SubagentOutput } from "./opencode2_subagent/output.ts"; import { openCode2SimpleInput } from "./opencode2_simple/input.ts"; +import { openCode2SkillInput } from "./opencode2_skill/input.ts"; +import { assertOpenCode2SkillOutput } from "./opencode2_skill/output.ts"; import { assertOpenCode2SimpleOutput } from "./opencode2_simple/output.ts"; import { openCode2ToolCallInput } from "./opencode2_tool_call/input.ts"; import { assertOpenCode2ToolCallOutput } from "./opencode2_tool_call/output.ts"; @@ -925,6 +931,45 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray ({ + name, + buildInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode"), + transcriptFile: new URL(`./${name}/opencode_transcript.ndjson`, import.meta.url), + modelSelection: { + instanceId: OPENCODE2_MODEL_SELECTION.instanceId, + model: "openrouter/deepseek/deepseek-v4-flash", + }, + assertOutput, + }, + ], + })), { name: "opencode_child_approval", buildInput: openCodeChildApprovalInput, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/input.ts new file mode 100644 index 000000000000..e06c97bfc856 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/input.ts @@ -0,0 +1,5 @@ +import { OPENCODE2_COMMAND_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2CommandInput(): OrchestratorFixtureInput { + return { steps: [{ type: "message", text: OPENCODE2_COMMAND_PROMPT }] }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/opencode_transcript.ndjson new file mode 100644 index 000000000000..a50529b58e8a --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/opencode_transcript.ndjson @@ -0,0 +1,30 @@ +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_command", "metadata": {"source": "oc2-8 live recording command_t3.ndjson (2.0.18, openrouter/deepseek/deepseek-v4-flash)", "capturedAt": "2026-09-29", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary: `/hello WORLD` names a workspace command (.opencode/command/hello.md), so T3 reads the directory's commands and runs it with session.command; OpenCode expands the template into the user message."}} +{"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} +{"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": ""}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "deepseek/deepseek-v4-flash", "modelID": "deepseek/deepseek-v4-flash", "providerID": "openrouter", "family": "deepseek-flash", "name": "DeepSeek V4 Flash", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openrouter", "settings": {"baseURL": "https://openrouter.ai/api/v1"}, "headers": {"HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [{"id": "none", "settings": {"reasoning": {"enabled": false}}}, {"id": "high", "settings": {"reasoning": {"effort": "high"}}}, {"id": "xhigh", "settings": {"reasoning": {"effort": "xhigh"}}}], "time": {"released": 1776988800000}, "cost": [{"input": 0.14, "output": 0.28, "cache": {"read": 0.028, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "output": 131072}}]}}} +{"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83284001dfaN03YCqzlu9F", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "projectID": "global", "model": {"id": "deepseek/deepseek-v4-flash", "providerID": "openrouter", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790747096015, "updated": 1790747096015}, "title": "oc2-8 command_t3", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}], "location": {"directory": ""}}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d833cf001vCmbwtG6y6Wg1U", "created": 1790747096015, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "slug": "shiny-squid", "version": "2.0.18", "projectID": "global", "location": {"directory": ""}, "subpath": "", "title": "oc2-8 command_t3", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}], "model": {"id": "deepseek/deepseek-v4-flash", "providerID": "openrouter"}}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "command.list", "frame": {"type": "command.list", "input": {"location[directory]": ""}}} +{"type": "emit_inbound", "label": "command.list.response", "frame": {"type": "sdk.response", "operation": "command.list", "data": {"location": {"directory": ""}, "data": [{"name": "init", "description": "guided AGENTS.md setup"}, {"name": "review", "description": "review changes [commit|branch|pr], defaults to uncommitted"}, {"name": "hello", "description": "Say hello to the workspace"}, {"name": "hello2", "description": "plural dir command"}]}}} +{"type": "emit_inbound", "label": "project.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d833e100154h183ZQ8TFIz9", "created": 1790747096033, "type": "project.updated", "data": {"id": "global", "canonical": "", "vcs": "git", "time": {"created": 1790744321645, "updated": 1790744321645, "active": 1790747096032}, "sandboxes": []}}}} +{"type": "expect_outbound", "label": "session.command", "frame": {"type": "session.command", "input": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "name": "hello", "text": "WORLD"}}} +{"type": "emit_inbound", "label": "session.command.response", "frame": {"type": "sdk.response", "operation": "session.command", "data": null}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d833f7001CR8gqnGibZ5mIH", "created": 1790747096055, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0f0d833f4001DhEyXmiqfz0kcF", "sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "item": {"type": "user", "payload": {"text": "Reply with exactly: HELLO WORLD"}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d834000013DTH87ptN5evlM", "created": 1790747096064, "type": "session.execution.started", "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC"}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d8341c001S6iTOkDN2My4re", "created": 1790747096092, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "delta": {"core/environment": "01f209da7564499aba1aa03a38a42dc4ed37f8e23457c5e19a54eba288092d1f", "core/date": "13b802fdef38a72e2e5d4cf7c6f9d270d07fb1787c3e4ff3ee1a8048fdbc46fa", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "b2aff856e4c0afa54b6d56ecdc438d66a6333b36a3b62677bb1a96da62079118"}}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83424001lpKlpS3FrbehRh", "created": 1790747096100, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "inboxID": "msg_0f0d833f4001DhEyXmiqfz0kcF"}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83b96001FPnNzwZ6AV16k8", "created": 1790747098007, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "agent": "build", "model": {"id": "deepseek/deepseek-v4-flash", "providerID": "openrouter", "variant": "default"}, "assistantMessageID": "msg_0f0d83436001gPzL6SiRXo8F4I", "snapshot": "5ecf8511987b833d27b391b832f802775c5ca199", "started": 1790747096184}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83b9e001UAO1OI95dfcaaz", "created": 1790747098014, "type": "session.reasoning.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "assistantMessageID": "msg_0f0d83436001gPzL6SiRXo8F4I", "ordinal": 0, "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83c09001XfcBot8eB8zZFK", "created": 1790747098121, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "assistantMessageID": "msg_0f0d83436001gPzL6SiRXo8F4I", "ordinal": 0, "delta": "The user wants me to reply with exactly \"HE"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83c6e001MNUwA0XRpI080b", "created": 1790747098222, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "assistantMessageID": "msg_0f0d83436001gPzL6SiRXo8F4I", "ordinal": 0, "delta": "LLO WORLD\"."}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83c730012DgvZk2fr1DfnJ", "created": 1790747098227, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "assistantMessageID": "msg_0f0d83436001gPzL6SiRXo8F4I", "ordinal": 0}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83c7e001YQNpM7Y1wngo5F", "created": 1790747098238, "type": "session.reasoning.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "assistantMessageID": "msg_0f0d83436001gPzL6SiRXo8F4I", "ordinal": 0, "text": "The user wants me to reply with exactly \"HELLO WORLD\".", "state": {"reasoningField": "reasoning_content", "reasoningDetails": [{"type": "reasoning.text", "text": "The user wants me to reply with exactly \"HELLO WORLD\".", "format": "unknown", "index": 0}]}}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83c87001yf9JFvPsdJjlay", "created": 1790747098247, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "assistantMessageID": "msg_0f0d83436001gPzL6SiRXo8F4I", "ordinal": 0, "delta": "HELLO WORLD"}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83c8b001TqVb5lHbF5gOdc", "created": 1790747098251, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "assistantMessageID": "msg_0f0d83436001gPzL6SiRXo8F4I", "ordinal": 0, "text": "HELLO WORLD"}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83c93001wMqGtozL9NskWl", "created": 1790747098259, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "assistantMessageID": "msg_0f0d83436001gPzL6SiRXo8F4I"}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 10, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83caa0019E9jLvh4HSJlas", "created": 1790747098282, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "assistantMessageID": "msg_0f0d83436001gPzL6SiRXo8F4I", "finish": "stop", "rawFinish": "stop", "cost": 0.00132454, "tokens": {"input": 9421, "output": 6, "reasoning": 14, "cache": {"read": 0, "write": 0}}, "snapshot": "5ecf8511987b833d27b391b832f802775c5ca199", "files": []}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83cb3001e6crfGqfQQcG2f", "created": 1790747098291, "type": "session.usage.updated", "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "cost": 0.00132454, "tokens": {"input": 9421, "output": 6, "reasoning": 14, "cache": {"read": 0, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83cb6001LaODbeTVaiuSZN", "created": 1790747098294, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC"}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 12, "version": 1}}}} +{"type": "runtime_exit", "status": "success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/output.ts new file mode 100644 index 000000000000..9c9d5066f236 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/output.ts @@ -0,0 +1,34 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_COMMAND_PROMPT, + projectionFor, +} from "../shared.ts"; + +/** + * `/hello WORLD` names a workspace command, so it runs through + * `session.command` and OpenCode expands the template ("Reply with exactly: + * HELLO $ARGUMENTS") in its own execution, which ends the turn. + */ +export function assertOpenCode2CommandOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertUserMessagesInclude(projection, [OPENCODE2_COMMAND_PROMPT]); + assertAssistantTextIncludes(projection, "HELLO WORLD"); + assert.deepEqual( + projection.providerTurns.map((turn) => turn.status), + ["completed"], + ); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/input.ts new file mode 100644 index 000000000000..5e3ee88aa559 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/input.ts @@ -0,0 +1,15 @@ +import { + OPENCODE2_COMPACTION_FIRST_PROMPT, + OPENCODE2_COMPACTION_RECALL_PROMPT, + type OrchestratorFixtureInput, +} from "../shared.ts"; + +export function openCode2CompactionInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_COMPACTION_FIRST_PROMPT }, + { type: "message", text: "/compact" }, + { type: "message", text: OPENCODE2_COMPACTION_RECALL_PROMPT }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/opencode_transcript.ndjson new file mode 100644 index 000000000000..357816f73f1c --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/opencode_transcript.ndjson @@ -0,0 +1,64 @@ +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_compaction", "metadata": {"source": "opencode2-spike/recordings/compaction.ndjson", "capturedAt": "2026-09-28", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: a turn, /compact through session.compact, then a turn that recalls the compacted fact."}} +{"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} +{"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": ""}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} +{"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b6d17001qoknVVU292OuxN", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f148492e3ffeQ91rU0GmbvwzYL", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657129772, "updated": 1790657129772}, "title": "spike compaction", "location": {"directory": ""}}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b6d2c0017hjEa3lkp3ZkkO", "created": 1790657129772, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "slug": "neon-wizard", "version": "2.0.18", "projectID": "global", "location": {"directory": ""}, "subpath": "", "title": "spike compaction", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7b6d3b001py2GMD5J7EycIR", "sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "time": {"created": 1790657129788}, "type": "user", "payload": {"text": "Remember the codeword PAPAYA. Reply OK."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b6d3c001Z9szA45fhovmFz", "created": 1790657129788, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7b6d3b001py2GMD5J7EycIR", "sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "item": {"type": "user", "payload": {"text": "Remember the codeword PAPAYA. Reply OK."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b6d40001eJ2vX4Y53fa1li", "created": 1790657129792, "type": "session.execution.started", "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL"}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b6d4a001Rtwo48i0kCK39W", "created": 1790657129803, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "delta": {"core/environment": "d1d3c4c9284e93400b07e0c6091f5878e0689220197cd9da0e5ec6bc1ef29400", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b6d4d0014pm1iNZj2rSON6", "created": 1790657129805, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "inboxID": "msg_0eb7b6d3b001py2GMD5J7EycIR"}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b732d001Is55JtuuHnq4vW", "created": 1790657131309, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7b6d54001EvEMVNzvyAv9G4", "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "started": 1790657129839}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b7330001BvFBYRjmA2ZlYb", "created": 1790657131312, "type": "session.reasoning.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7b6d54001EvEMVNzvyAv9G4", "ordinal": 0, "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b7397001ldRt5gXe02BVq9", "created": 1790657131415, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7b6d54001EvEMVNzvyAv9G4", "ordinal": 0, "delta": "The user asks"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b7564001Gwu6lcemJ9qlzm", "created": 1790657131876, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7b6d54001EvEMVNzvyAv9G4", "ordinal": 0, "delta": " me to remember a codeword and reply OK. Simple.\n\nBut \"remember\" \u2014 should I write it to a file? The instruction is trivial"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b76c80013EB6BN6n4DkFIJ", "created": 1790657132232, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7b6d54001EvEMVNzvyAv9G4", "ordinal": 0, "delta": ". I'll just confirm. Maybe I should note it in a file to genuinely \"remember\"? That's overkill; but a"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b77b1001eZjYfm11S3rco2", "created": 1790657132465, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7b6d54001EvEMVNzvyAv9G4", "ordinal": 0, "delta": " light note in AGENTS.md could be reasonable. The user just said reply OK. I'll reply OK concisely."}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b77b20011B4WYK10VUb09P", "created": 1790657132466, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7b6d54001EvEMVNzvyAv9G4", "ordinal": 0}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b77e4001xxKkGry8hhu6zM", "created": 1790657132516, "type": "session.reasoning.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7b6d54001EvEMVNzvyAv9G4", "ordinal": 0, "text": "The user asks me to remember a codeword and reply OK. Simple.\n\nBut \"remember\" \u2014 should I write it to a file? The instruction is trivial. I'll just confirm. Maybe I should note it in a file to genuinely \"remember\"? That's overkill; but a light note in AGENTS.md could be reasonable. The user just said reply OK. I'll reply OK concisely.", "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b77e9001IIiq99XFPVP31O", "created": 1790657132521, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7b6d54001EvEMVNzvyAv9G4", "ordinal": 0, "delta": "OK \u2014 noted: **PAPAYA**."}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b77ea001JIRc7glJTTM1K8", "created": 1790657132522, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7b6d54001EvEMVNzvyAv9G4", "ordinal": 0, "text": "OK \u2014 noted: **PAPAYA**."}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b77ed001uFH78ru4Imk9Vo", "created": 1790657132525, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7b6d54001EvEMVNzvyAv9G4"}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 10, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b77fa0015N4h5OuUzTEdIW", "created": 1790657132538, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7b6d54001EvEMVNzvyAv9G4", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 8749, "output": 8, "reasoning": 80, "cache": {"read": 487, "write": 0}}, "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": []}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b77fe001vSHE1jIFT2tjt7", "created": 1790657132543, "type": "session.usage.updated", "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "cost": 0, "tokens": {"input": 8749, "output": 8, "reasoning": 80, "cache": {"read": 487, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b7800001R5UTLu4HvZXfgz", "created": 1790657132544, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL"}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 12, "version": 1}}}} +{"type": "expect_outbound", "label": "session.compact", "frame": {"type": "session.compact", "input": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL"}}} +{"type": "emit_inbound", "label": "session.compact.response", "frame": {"type": "sdk.response", "operation": "session.compact", "data": {"data": {"id": "msg_0eb7b780a001XcfF1GW3GpxVoC", "sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "time": {"created": 1790657132555}, "type": "compaction", "payload": {}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b780b001gDTNvPNq7ZliWh", "created": 1790657132555, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7b780a001XcfF1GW3GpxVoC", "sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "item": {"type": "compaction", "payload": {}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 13, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b780f001rhbaOox3ks1up9", "created": 1790657132559, "type": "session.execution.started", "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL"}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 14, "version": 1}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b7814001lhk2XjLIhRj2RZ", "created": 1790657132564, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "inboxID": "msg_0eb7b780a001XcfF1GW3GpxVoC"}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 15, "version": 1}}}} +{"type": "emit_inbound", "label": "session.compaction.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b7814002kIpoXs766ZOd4I", "created": 1790657132564, "type": "session.compaction.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "reason": "manual", "recent": "", "inputID": "msg_0eb7b780a001XcfF1GW3GpxVoC"}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 16, "version": 1}}}} +{"type": "emit_inbound", "label": "session.compaction.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b7baa001dtiOb7RSAXdZHf", "created": 1790657133482, "type": "session.compaction.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "text": "## Objective\n- User asked the"}}}} +{"type": "emit_inbound", "label": "session.compaction.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b7ceb001Fdnt7iV0eGG5P0", "created": 1790657133803, "type": "session.compaction.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "text": " assistant to retain the codeword PAPAYA and confirm; no substantive task was started.\n\n## Requirements\n- Remember the codeword `PAPAYA` for the remainder"}}}} +{"type": "emit_inbound", "label": "session.compaction.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b7e730019MM5GfpoXHmVCj", "created": 1790657134195, "type": "session.compaction.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "text": " of the session.\n- Reply exactly \"OK\" to that request.\n\n## Decisions\n- Confirmed with \"OK \u2014 noted: **PAPALA**...\" ("}}}} +{"type": "emit_inbound", "label": "session.compaction.delta.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b80790012vR7Upn2tjIMvE", "created": 1790657134713, "type": "session.compaction.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "text": "actual reply: \"OK \u2014 noted: **PAPAYA**.\") without persisting the codeword to any file, since none was requested.\n\n## Work State\n### Completed\n- Codeword `PAPAYA` acknowledged and retained in conversation context.\n###"}}}} +{"type": "emit_inbound", "label": "session.compaction.delta.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b8242001nbiV4BiNkzKkWZ", "created": 1790657135170, "type": "session.compaction.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "text": " Active\n- (none)\n### Blocked\n- (none)\n\n## Next Move\n1. (none) \u2014 await the user's next request; recall `PAPAYA` if"}}}} +{"type": "emit_inbound", "label": "session.compaction.delta.6", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b83d70011cjoTGPM7Xnjfd", "created": 1790657135575, "type": "session.compaction.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "text": " asked.\n\n## Relevant Files\n- (none)\n\n## Important Context\n- Standing fact to preserve: the session code"}}}} +{"type": "emit_inbound", "label": "session.compaction.delta.7", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b860a001AFR9cj59hJIDwo", "created": 1790657136138, "type": "session.compaction.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "text": "word is `PAPAYA`.\n- No files were read, created, or modified; no git operations were performed."}}}} +{"type": "emit_inbound", "label": "session.usage.updated.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b860f001WCcobWw9S7zfD3", "created": 1790657136143, "type": "session.usage.updated", "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "cost": 0, "tokens": {"input": 9315, "output": 212, "reasoning": 80, "cache": {"read": 9809, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.compaction.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b860f002eWB6gs4trosBH7", "created": 1790657136143, "type": "session.compaction.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "reason": "manual", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "text": "## Objective\n- User asked the assistant to retain the codeword PAPAYA and confirm; no substantive task was started.\n\n## Requirements\n- Remember the codeword `PAPAYA` for the remainder of the session.\n- Reply exactly \"OK\" to that request.\n\n## Decisions\n- Confirmed with \"OK \u2014 noted: **PAPALA**...\" (actual reply: \"OK \u2014 noted: **PAPAYA**.\") without persisting the codeword to any file, since none was requested.\n\n## Work State\n### Completed\n- Codeword `PAPAYA` acknowledged and retained in conversation context.\n### Active\n- (none)\n### Blocked\n- (none)\n\n## Next Move\n1. (none) \u2014 await the user's next request; recall `PAPAYA` if asked.\n\n## Relevant Files\n- (none)\n\n## Important Context\n- Standing fact to preserve: the session codeword is `PAPAYA`.\n- No files were read, created, or modified; no git operations were performed.", "recent": "", "tokens": {"input": 566, "output": 204, "reasoning": 0, "cache": {"read": 9322, "write": 0}}, "cost": 0}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 18, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b8612001tVkbqOqeBih1Ll", "created": 1790657136146, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL"}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 19, "version": 1}}}} +{"type": "emit_inbound", "label": "project.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b8615001mr65rqcwAcFyvw", "created": 1790657136149, "type": "project.updated", "location": {"directory": ""}, "data": {"id": "global", "canonical": "", "vcs": "git", "time": {"created": 1790656063718, "updated": 1790656063718, "active": 1790657136149}, "sandboxes": []}}}} +{"type": "expect_outbound", "label": "session.prompt.2", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "text": ""}}} +{"type": "emit_inbound", "label": "session.prompt.response.2", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7bfb4e001HDB4rcr4Vmg7Lo", "sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "time": {"created": 1790657166160}, "type": "user", "payload": {"text": "What was the codeword? One word."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7bfb50001CNYzVC3z1XJMAU", "created": 1790657166160, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7bfb4e001HDB4rcr4Vmg7Lo", "sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "item": {"type": "user", "payload": {"text": "What was the codeword? One word."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 20, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7bfb53001TGvBQ4Ib9UsXb8", "created": 1790657166163, "type": "session.execution.started", "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL"}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 21, "version": 1}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7bfb5d001t26l2UqC64OohP", "created": 1790657166173, "type": "session.inbox.delivered", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "inboxID": "msg_0eb7bfb4e001HDB4rcr4Vmg7Lo"}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 22, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c01a3001keJMrATRduSPB8", "created": 1790657167779, "type": "session.step.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7bfb640015heJPNiXFxEgy7", "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "started": 1790657166204}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 23, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c01a6001u5J6dvPnKW03Jp", "created": 1790657167782, "type": "session.reasoning.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7bfb640015heJPNiXFxEgy7", "ordinal": 0, "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 24, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c020f001lNdEhyfyVixb7u", "created": 1790657167887, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7bfb640015heJPNiXFxEgy7", "ordinal": 0, "delta": "The user asks: \""}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.6", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c0280001YxaoqDjzLlrO1e", "created": 1790657168000, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7bfb640015heJPNiXFxEgy7", "ordinal": 0, "delta": "What was the"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.7", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c02c2001DwROJWuoiBG2y2", "created": 1790657168066, "type": "session.reasoning.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7bfb640015heJPNiXFxEgy7", "ordinal": 0, "delta": " codeword? One word.\" The answer is PAPAYA."}}}} +{"type": "emit_inbound", "label": "session.text.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c02c3001sxAGuiN0LNpRcZ", "created": 1790657168067, "type": "session.text.started", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7bfb640015heJPNiXFxEgy7", "ordinal": 0}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 25, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c02eb001GHmLfN035FrbOZ", "created": 1790657168107, "type": "session.reasoning.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7bfb640015heJPNiXFxEgy7", "ordinal": 0, "text": "The user asks: \"What was the codeword? One word.\" The answer is PAPAYA.", "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 26, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c02ee001zGEdjIkNnsZd4k", "created": 1790657168111, "type": "session.text.delta", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7bfb640015heJPNiXFxEgy7", "ordinal": 0, "delta": "PAPAYA"}}}} +{"type": "emit_inbound", "label": "session.text.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c02ef001WlN2xvkRmnCZqc", "created": 1790657168111, "type": "session.text.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7bfb640015heJPNiXFxEgy7", "ordinal": 0, "text": "PAPAYA"}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 27, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c02f2001HDBj6SZ2II4LQL", "created": 1790657168114, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7bfb640015heJPNiXFxEgy7"}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 28, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c02ff001wzBeYrGmx296qW", "created": 1790657168127, "type": "session.step.ended", "location": {"directory": ""}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "assistantMessageID": "msg_0eb7bfb640015heJPNiXFxEgy7", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 8941, "output": 5, "reasoning": 20, "cache": {"read": 489, "write": 0}}, "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": []}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 29, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c0303001Hjd9WD3c7ZHBbz", "created": 1790657168131, "type": "session.usage.updated", "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "cost": 0, "tokens": {"input": 18256, "output": 217, "reasoning": 100, "cache": {"read": 10298, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c0305001Y1XqEjf7mSV4YW", "created": 1790657168133, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL"}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 30, "version": 1}}}} +{"type": "runtime_exit", "status": "success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/output.ts new file mode 100644 index 000000000000..bc7c3b6d1a31 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/output.ts @@ -0,0 +1,61 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_COMPACTION_FIRST_PROMPT, + OPENCODE2_COMPACTION_RECALL_PROMPT, + projectionFor, +} from "../shared.ts"; + +/** + * `/compact` runs `session.compact`: OpenCode queues a compaction item and + * summarizes in its own execution, which ends the `/compact` turn. The next + * turn answers from the summary. + */ +export function assertOpenCode2CompactionOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ + result, + transcript, + runCount: 3, + runStatuses: ["completed", "completed", "completed"], + }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertUserMessagesInclude(projection, [ + OPENCODE2_COMPACTION_FIRST_PROMPT, + "/compact", + OPENCODE2_COMPACTION_RECALL_PROMPT, + ]); + + const compactRun = projection.runs[1]; + const compactions = projection.turnItems.filter((item) => item.type === "compaction"); + assert.lengthOf(compactions, 1); + const [compaction] = compactions; + assert.equal(compaction?.runId, compactRun?.id); + assert.equal(compaction?.status, "completed"); + assert.equal(compaction?.title, "Context compacted"); + // The summary OpenCode carries forward is `compaction.ended`'s full text. + assert.include(compaction?.type === "compaction" ? compaction.summary : "", "PAPAYA"); + assert.include(compaction?.type === "compaction" ? compaction.summary : "", "## Objective"); + // The `/compact` run shows no assistant reply; the recall run answers from the summary. + assert.isUndefined( + projection.turnItems.find( + (item) => item.type === "assistant_message" && item.runId === compactRun?.id, + ), + ); + assertAssistantTextIncludes(projection, "PAPAYA"); + assert.deepEqual( + projection.providerTurns.map((turn) => turn.status), + ["completed", "completed", "completed"], + ); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/input.ts new file mode 100644 index 000000000000..4019a952e2fb --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/input.ts @@ -0,0 +1,5 @@ +import { OPENCODE2_SKILL_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2SkillInput(): OrchestratorFixtureInput { + return { steps: [{ type: "message", text: OPENCODE2_SKILL_PROMPT }] }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/opencode_transcript.ndjson new file mode 100644 index 000000000000..dc9c5ca63dca --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/opencode_transcript.ndjson @@ -0,0 +1,30 @@ +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_skill", "metadata": {"source": "oc2-8 live recording skill_t3.ndjson (2.0.18, openrouter/deepseek/deepseek-v4-flash)", "capturedAt": "2026-09-29", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary: a prompt naming `$greet`, a workspace skill (.opencode/skills/greet/SKILL.md), so T3 reads the directory's skills and attaches it with prompt{skills}. Machine-wide skills are dropped from the recorded list."}} +{"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} +{"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": ""}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "deepseek/deepseek-v4-flash", "modelID": "deepseek/deepseek-v4-flash", "providerID": "openrouter", "family": "deepseek-flash", "name": "DeepSeek V4 Flash", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openrouter", "settings": {"baseURL": "https://openrouter.ai/api/v1"}, "headers": {"HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [{"id": "none", "settings": {"reasoning": {"enabled": false}}}, {"id": "high", "settings": {"reasoning": {"effort": "high"}}}, {"id": "xhigh", "settings": {"reasoning": {"effort": "xhigh"}}}], "time": {"released": 1776988800000}, "cost": [{"input": 0.14, "output": 0.28, "cache": {"read": 0.028, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "output": 131072}}]}}} +{"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d83f30001TUsYBmqL72JOVk", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": ""}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "projectID": "global", "model": {"id": "deepseek/deepseek-v4-flash", "providerID": "openrouter", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790747099267, "updated": 1790747099267}, "title": "oc2-8 skill_t3", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}], "location": {"directory": ""}}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84083001QSHajGz7TcpzgV", "created": 1790747099267, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "slug": "happy-rocket", "version": "2.0.18", "projectID": "global", "location": {"directory": ""}, "subpath": "", "title": "oc2-8 skill_t3", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}], "model": {"id": "deepseek/deepseek-v4-flash", "providerID": "openrouter"}}, "durable": {"aggregateID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "skill.list", "frame": {"type": "skill.list", "input": {"location[directory]": ""}}} +{"type": "emit_inbound", "label": "skill.list.response", "frame": {"type": "sdk.response", "operation": "skill.list", "data": {"location": {"directory": ""}, "data": [{"id": "opencode", "name": "OpenCode", "description": "Use this skill for any question about OpenCode itself, including how OpenCode works, using or configuring it, migrating from V1 to V2, troubleshooting it, developing plugins or integrations, using the OpenCode SDK, clients, server, or API, and contributing to the OpenCode codebase. Also use it for OpenCode agents, commands, skills, tools, permissions, MCP servers, providers, models, themes, keybinds, formatters, the CLI, TUI, desktop app, and web app.", "path": "/builtin/opencode.md", "content": "# OpenCode\n\nUse this guide as the starting point for work involving OpenCode itself. It\ncovers the core concepts needed to configure and customize OpenCode, extend it\nwith plugins, and build integrations with the OpenCode SDK, clients, and API.\n\nFull documentation is available at . This overview is\nonly an index of core concepts. Before answering a question about a topic below,\nfetch the URL named in that section and use the full page as the source of\ntruth. Follow links from that page when the question needs more detail. Fetch\n first when you need to discover the relevant\ndocumentation page.\n\nA machine-readable documentation index is available at\n.\n\n## Version policy\n\nAlways answer for OpenCode V2 unless the user explicitly asks about V1,\nlegacy OpenCode, or migrating from V1.\n\nUse only documentation as the source of truth for V2.\nDo not use , which documents V1, and do not use\ngeneral web search to resolve a V2 documentation question when the V2 docs or\nlinked pages cover it. The schema served from\n may describe V1 even though V2 configuration\nfiles include that URL for editor integration. Never use it to infer V2 field\nnames or shapes. If V2 documentation is missing or contradictory, state the\nuncertainty or ask for clarification instead of falling back to V1.\n\nV1 documentation and syntax may be consulted only when the user explicitly\nasks about V1 or when needed as migration input. Outputs and recommendations\nmust still use V2 unless the user specifically requests a V1 result.\n\n## [CLI](https://opencode.ai/v2/docs/cli)\n\nFor questions about the terminal interface, command-line invocation, `run`,\n`mini`, terminal providers, or other CLI behavior, fetch the\n[CLI guide](https://opencode.ai/v2/docs/cli) and the relevant page linked from\nthat section.\n\nCLI and TUI preferences are separate from OpenCode's server and project\nconfiguration. They live in the global `~/.config/opencode/cli.json`, or\n`$XDG_CONFIG_HOME/opencode/cli.json` when `XDG_CONFIG_HOME` is set. There is no\nproject-local CLI configuration. Set `OPENCODE_CLI_CONFIG_CONTENT` to merge\ninline JSON over the global settings. Most preferences can also be changed from\nthe TUI by pressing `Ctrl+P` and selecting **Open settings**.\n\n### [Settings](https://opencode.ai/v2/docs/cli/config)\n\nFetch the full [CLI settings reference](https://opencode.ai/v2/docs/cli/config)\nbefore editing `cli.json`. It documents every terminal-only setting, accepted\nvalues, and examples, including themes, input, sessions, tabs, diffs, alerts,\nMini, keybindings, terminal plugins, and debugging. Do not put these settings\nin `opencode.json(c)`.\n\n### [Keybinds](https://opencode.ai/v2/docs/cli/keybinds)\n\nConfigure keybindings under `keybinds` in `cli.json`. The leader key is the\n`keybinds.leader` entry; leader timing is configured separately under\n`leader.timeout`. Bindings can use a string, an array of strings, or an object\nwhen event behavior such as `preventDefault` is required. Disable a binding\nwith `\"none\"` or `false`.\n\nNever guess a command ID, default binding, or accepted key syntax. Fetch the\nfull [keybind reference](https://opencode.ai/v2/docs/cli/keybinds), which lists\nthe current IDs and defaults, before answering or editing a binding.\n\n## [OpenCode configuration](https://opencode.ai/v2/docs/config)\n\nOpenCode's server and project configuration uses JSON or JSONC. Include the\npublished schema so the user's editor can validate fields and provide\nautocomplete:\n\n```jsonc\n{\n \"$schema\": \"https://opencode.ai/config.json\",\n}\n```\n\nGlobal configuration lives at `~/.config/opencode/opencode.json(c)` and applies\nto every project for that user. Project configuration can live in any directory\nas `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages\nin a monorepo.\n\nDuring ordinary project discovery, OpenCode searches the current Location\ndirectory and every ancestor through the filesystem root, including directories\nabove the detected project or repository root. It merges direct\n`opencode.json(c)` files from the farthest ancestor to the current directory,\nthen does the same for `.opencode/opencode.json(c)` files. This means every\ndiscovered `.opencode` config overrides every discovered direct config. Global\nfilesystem configuration has lower precedence than these discovered documents.\n\nCommon configuration fields include `model`, `default_agent`, `permissions`,\n`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,\n`references`, `formatter`, and `lsp`.\n\nThis configuration is distinct from `cli.json`. Use the\n[CLI settings reference](https://opencode.ai/v2/docs/cli/config) for terminal\npreferences, especially themes and keybindings.\n\nDo not guess field names or shapes. Fetch the V2 configuration guide and its\nlinked topic guide as the source of truth, and preserve unrelated settings when\nediting an existing file. Keep the published `$schema` URL in configuration\nexamples, but do not fetch it to determine the V2 configuration shape.\n\nSee the [full configuration guide](https://opencode.ai/v2/docs/config) for\nevery field, examples, config locations, and links to dedicated feature guides.\n\n## [MCP servers](https://opencode.ai/v2/docs/mcp-servers)\n\nConfigure MCP servers under `mcp.servers`. Prefer the CLI because it preserves\nunrelated configuration. Use `--global` when the user asks to set up a service\nfor themselves without limiting it to the current project; omit it when they\nexplicitly want project-local configuration.\n\n```sh\nopencode mcp add --global --url \nopencode mcp list\n```\n\nRemote servers use OAuth by default. If `mcp list` reports that a server needs\nauthentication, tell the user to run `/mcps`, select the server, and sign in.\nDo not run `opencode mcp auth` through the shell tool: it starts an interactive\nflow whose authorization link can be hidden in background process output.\nUse the user-facing MCP interface instead.\n\nReport the server as configured but awaiting sign-in until its connection\nstatus confirms it is connected.\n\nOAuth credentials are stored outside the OpenCode configuration. Do not ask for or\nstore an API key when the server supports OAuth. Use header-based credentials\nonly when OAuth is unavailable or the user explicitly requires them, and use an\nenvironment substitution such as `{env:MCP_API_KEY}` instead of writing a\nsecret into configuration.\n\n## [V1 to V2 migration](https://opencode.ai/v2/docs/migrate-v1)\n\nFor any request to migrate OpenCode configuration, agents, commands, skills,\nplugins, integrations, or other behavior from V1 to V2, read the full\n[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In\nthe repository, its source is `services/www/src/docs/content/migrate-v1.mdx`.\n\nV1 config files and `.opencode/` definitions are intended to remain compatible.\nThe only intentional breaking changes are the server API and plugin API. Native\nV2 config uses more ergonomic shapes, but conversion is optional. When the user\nrequests conversion, inspect the complete configuration, preserve behavior and\nunrelated settings, and apply only the relevant migrations from the guide. For\nplugin migrations, fetch and follow both the migration guide and the full\n[plugins guide](https://opencode.ai/v2/docs/build/plugins). If non-API V1\nfunctionality fails in V2, use the `report` skill to file it as a compatibility\nbug.\n\n## [Plugins](https://opencode.ai/v2/docs/build/plugins)\n\nFor questions about creating, configuring, loading, publishing, or migrating\nplugins, fetch the full [plugins guide](https://opencode.ai/v2/docs/build/plugins)\nbefore answering. Refer to this guide when the user wants to build a plugin. It\ncovers hooks, transforms, tools, plugin context capabilities, and package\nentrypoints. Plugins can also extend the TUI; for those, fetch the\n[CLI plugin guide](https://opencode.ai/v2/docs/build/plugins/cli).\nFor custom methods and events shared with other plugins or clients, fetch the\n[RPC guide](https://opencode.ai/v2/docs/build/plugins/rpc).\n\n## [Service](https://opencode.ai/v2/docs/troubleshooting#check-the-background-service)\n\nOpenCode uses a client-server architecture. Interfaces such as the TUI connect\nto a background OpenCode service, which owns sessions, configuration, plugins,\npermissions, and tool execution.\n\nOpenCode normally discovers or starts the shared background service\nautomatically. If the service is stuck or unhealthy, restart it:\n\n```sh\nopencode service restart\n```\n\nCheck its status after restarting:\n\n```sh\nopencode service status\n```\n\n## [API](https://opencode.ai/v2/docs/api)\n\nOpenCode exposes an HTTP API from its server. The API is described by an\nOpenAPI document available from the running server at `/openapi.json`.\n\nUse OpenCode's built-in `api` command for local requests. It uses the same\ndiscovery and authentication flow as the TUI and may start the background\nservice when no compatible healthy service is available. It accepts either an\nHTTP method and path or an OpenAPI operation ID.\n\nCall an endpoint with an HTTP method and path:\n\n```sh\nopencode api get /api/info\n```\n\nPass a request body with `--data` or `-d`, and additional headers with\n`--header` or `-H`:\n\n```sh\nopencode api post /api/example --data '{\"key\":\"value\"}'\nopencode api get /api/example --header 'X-Example:value'\n```\n\nRequest bodies default to `Content-Type: application/json`. When OpenCode is\nconnected to an explicit server instead of its managed background service, use\nthe same configured server and authentication context rather than constructing\nan unauthenticated request separately.\n\nSee the [full API reference](https://opencode.ai/v2/docs/api) for available\nendpoints, parameters, request bodies, and response schemas. The\nraw [OpenAPI specification](https://opencode.ai/v2/openapi.json) is also\navailable for code generation and other tooling.\n\n## [Client](https://opencode.ai/v2/docs/build/client)\n\nFor questions about connecting an application to OpenCode over the network,\nfetch the full [client guide](https://opencode.ai/v2/docs/build/client) before\nanswering.\n\n`@opencode/client` is the generated TypeScript client for the OpenCode HTTP\nAPI. Its methods and types come from the same contract as the API reference.\nThe default entrypoint exposes Promise-based resource clients and async\niterables for streaming endpoints. The `@opencode/client/effect` entrypoint\nexposes typed Effects, Streams, and decoded OpenCode schema values. Its\n`Service` API can discover, start, stop, and authenticate with the local\nbackground service from a Node application.\n\n## [SDK](https://opencode.ai/v2/docs/build/sdk)\n\nFor questions about embedding OpenCode directly in an application, fetch the\nfull [SDK guide](https://opencode.ai/v2/docs/build/sdk) before answering. The SDK\nhosts OpenCode in the application without opening an HTTP listener.\n\nUse the [Effect SDK guide](https://opencode.ai/v2/docs/build/sdk/effect) for\nEffect applications. For Cloudflare Durable Objects, use the\n[Cloudflare SDK guide](https://opencode.ai/v2/docs/build/sdk/cloudflare).\n\n## [Troubleshooting](https://opencode.ai/v2/docs/troubleshooting)\n\nOpenCode runs a client and a background server. Start by determining whether a\nproblem belongs to the client, the shared server, or one project.\n\n- Check the service with `opencode service status` and verify the API with\n `opencode api get /api/info`.\n- Compare with `opencode --standalone`, which runs the TUI with a private\n server, to isolate shared-service issues.\n- Inspect `~/.local/share/opencode/log/opencode.log`. Filter `role=cli` for\n client startup and `role=server` for sessions, providers, plugins,\n permissions, and tools.\n- Run one reproduction with `OPENCODE_LOG_LEVEL=DEBUG` when normal logs are not\n sufficient.\n- Do not delete or edit the database, service registration, or service config\n while diagnosing a problem. Back up persistent data before inspecting it\n with external tools.\n- Redact API keys, authorization headers, prompts, file contents, and other\n sensitive data before sharing diagnostics.\n\nSee the [full troubleshooting guide](https://opencode.ai/v2/docs/troubleshooting)\nfor service lifecycle commands, API inspection, log locations, explicit server\nconnections, issue-reporting details, and local development paths.\n"}, {"id": "report", "name": "Report", "description": "Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI.", "path": "/builtin/report.md", "content": "\n\n# Report an opencode Issue\n\nUse this skill when the user wants to report an opencode issue or bug. Your job\nis to turn the user's problem into a useful GitHub issue with standard\ndiagnostics plus the context needed to reproduce and resolve it.\n\n## Workflow\n\n1. Collect the standard diagnostics below.\n2. Ask only for missing details that are necessary to reproduce or understand\n impact.\n3. Draft the issue in the standard format below.\n4. Publish it with GitHub CLI after the user confirms the title and body.\n\nDo not publish an issue without user confirmation. If GitHub CLI is not\ninstalled or not authenticated, explain the blocker and provide the exact issue\ntitle/body for the user.\n\n## Standard Diagnostics\n\nCollect these values when possible:\n\n- opencode version: run `opencode --version`.\n- Operating system: run `uname -a` on Unix-like systems, or `ver` on Windows.\n- Terminal: inspect `$TERM`, `$TERM_PROGRAM`, `$COLORTERM`, and any obvious\n terminal app context the user provides.\n- Shell: inspect `$SHELL` on Unix-like systems, or `%COMSPEC%`/`$ComSpec` on\n Windows when relevant.\n- Install/channel context: include whether this appears to be local, dev, beta,\n or release if the version output or environment reveals it.\n- Active plugins: inspect opencode config for configured plugins when possible.\n Check likely config locations such as `opencode.json`, `opencode.jsonc`,\n `.opencode/opencode.json`, and `~/.config/opencode/opencode.json`. Record\n configured plugin entries, local plugin files under `.opencode/plugin/` or\n `.opencode/plugins/`, and note if plugin status could not be determined.\n\nIf a diagnostic command fails, include `Unavailable` with the reason instead of\nguessing.\n\n## User-Specific Context\n\nCapture the details that make the issue actionable:\n\n- What the user was trying to do.\n- What happened.\n- What the user expected to happen.\n- Reproduction steps, ideally minimal and numbered.\n- Relevant logs, stack traces, screenshots, terminal output, or config snippets.\n- Whether the issue is reproducible consistently, intermittently, or only once.\n- Recent changes that may be related, such as updating opencode, changing\n config, installing a plugin, changing terminal, or switching workspace.\n- Workarounds tried and whether they helped.\n\nAvoid pasting secrets. Redact tokens, API keys, private URLs, usernames, and\nproject-specific confidential data unless the user explicitly says it is safe.\n\n## Issue Format\n\nUse this exact structure unless the repository issue template requires\notherwise:\n\n```markdown\n## Summary\n\n\n\n## Environment\n\n- opencode version: \n- OS: \n- Terminal: \n- Shell: \n- Install/channel: \n- Active plugins: \n\n## Reproduction\n\n1. \n2. \n3. \n\n## Expected Behavior\n\n\n\n## Actual Behavior\n\n\n\n## Additional Context\n\n\n```\n\nKeep the title short and searchable. Prefer the form:\n\n```text\n: \n```\n\nExamples: `tui: skills dialog crashes outside location provider`,\n`cli: local service config writes release filename`.\n\n## Publishing With GitHub CLI\n\nUse GitHub CLI from the repository checkout when available:\n\n```sh\ngh issue create --title \"\" --body-file <file>\n```\n\nWrite the body to a temporary markdown file first so quoting, newlines, logs,\nand code fences are preserved. If the issue belongs in a specific repository,\nuse `--repo owner/name`. If labels are obvious and the repo accepts them, add\n`--label bug`; otherwise omit labels rather than guessing.\n\nAfter publishing, report the created issue URL to the user and mention any\ndiagnostics that were unavailable.\n\n\n## Runtime Diagnostics Snapshot\n\nThese values were captured when the built-in report skill was registered. Verify them before publishing.\n\n- opencode version: 2.0.18\n- install/channel: latest\n- OS: Linux 6.12.107+deb13-amd64 (linux x64)\n- Terminal: Unavailable: terminal environment variables are not set\n- Shell: /bin/bash\n- Active plugins: None found in config"}, {"id": "greet4", "name": "greet4", "description": "Test skill greet4.", "path": "<work>/.claude/skills/greet4/SKILL.md", "content": "Begin reply with MANGO.\n"}, {"id": "greet3", "name": "greet3", "description": "Test skill greet3.", "path": "<work>/.agents/skills/greet3/SKILL.md", "content": "Begin reply with MANGO.\n"}, {"id": "greet2", "name": "greet2", "description": "Test skill greet2.", "path": "<work>/.opencode/skill/greet2/SKILL.md", "content": "Begin reply with MANGO.\n"}, {"id": "greet", "name": "greet", "description": "Greets the user with the secret word MANGO.", "path": "<work>/.opencode/skills/greet/SKILL.md", "content": "When this skill is active, begin your reply with the exact word MANGO.\n"}]}}} +{"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "text": "<any>", "skills": [{"id": "greet"}]}}} +{"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0f0d840b8001XDyw6VwPddNBg9", "sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "time": {"created": 1790747099324}, "type": "user", "payload": {"text": "Use $greet to say hi in three words.", "skills": [{"id": "greet", "name": "greet", "text": "<skill_content name=\"greet\">\n# Skill: greet\n\nWhen this skill is active, begin your reply with the exact word MANGO.\n\nBase directory for this skill: <work>/.opencode/skills/greet\nRelative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.\nNote: file list is sampled.\n\n<skill_files>\n</skill_files>\n</skill_content>"}]}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d840bc0012jnZcPMQkCrzZZ", "created": 1790747099324, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0f0d840b8001XDyw6VwPddNBg9", "sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "item": {"type": "user", "payload": {"text": "Use $greet to say hi in three words.", "skills": [{"id": "greet", "name": "greet", "text": "<skill_content name=\"greet\">\n# Skill: greet\n\nWhen this skill is active, begin your reply with the exact word MANGO.\n\nBase directory for this skill: <work>/.opencode/skills/greet\nRelative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.\nNote: file list is sampled.\n\n<skill_files>\n</skill_files>\n</skill_content>"}]}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d840c5001TdFc053Ir7OFUX", "created": 1790747099333, "type": "session.execution.started", "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l"}, "durable": {"aggregateID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d840d7001erSgLH5OCGHOir", "created": 1790747099351, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "delta": {"core/environment": "77d8763dc7eb069443db3ad78e1a23e2baee2ba53e81e5083f0d10da50e06f0b", "core/date": "13b802fdef38a72e2e5d4cf7c6f9d270d07fb1787c3e4ff3ee1a8048fdbc46fa", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "b2aff856e4c0afa54b6d56ecdc438d66a6333b36a3b62677bb1a96da62079118"}}, "durable": {"aggregateID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d840de001PhbTwU8T9kW4Wz", "created": 1790747099358, "type": "session.inbox.delivered", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "inboxID": "msg_0f0d840b8001XDyw6VwPddNBg9"}, "durable": {"aggregateID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84cdf001F1QlB7K0j54A5Q", "created": 1790747102431, "type": "session.step.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "agent": "build", "model": {"id": "deepseek/deepseek-v4-flash", "providerID": "openrouter", "variant": "default"}, "assistantMessageID": "msg_0f0d840eb001q13lyy3XcbFJ2Z", "snapshot": "5ecf8511987b833d27b391b832f802775c5ca199", "started": 1790747099406}, "durable": {"aggregateID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84ce7001noyLcs2AWcf1nA", "created": 1790747102439, "type": "session.reasoning.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "assistantMessageID": "msg_0f0d840eb001q13lyy3XcbFJ2Z", "ordinal": 0, "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84d53001b7IeUCyzYiZJ7r", "created": 1790747102547, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "assistantMessageID": "msg_0f0d840eb001q13lyy3XcbFJ2Z", "ordinal": 0, "delta": "The user has loaded the \"greet\" skill which"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84e2600179Znuph457Qgr2", "created": 1790747102758, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "assistantMessageID": "msg_0f0d840eb001q13lyy3XcbFJ2Z", "ordinal": 0, "delta": " says the skill should begin my reply with the exact word MANGO. They're"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84ea5001hLdiVBCFB1ssQu", "created": 1790747102885, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "assistantMessageID": "msg_0f0d840eb001q13lyy3XcbFJ2Z", "ordinal": 0, "delta": " asking me to use $greet to say hi in three words."}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84ea9001t1GafN7t25nGqJ", "created": 1790747102889, "type": "session.text.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "assistantMessageID": "msg_0f0d840eb001q13lyy3XcbFJ2Z", "ordinal": 0}, "durable": {"aggregateID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84ee3001RUjUz8fvJKtKfT", "created": 1790747102947, "type": "session.reasoning.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "assistantMessageID": "msg_0f0d840eb001q13lyy3XcbFJ2Z", "ordinal": 0, "text": "The user has loaded the \"greet\" skill which says the skill should begin my reply with the exact word MANGO. They're asking me to use $greet to say hi in three words.", "state": {"reasoningField": "reasoning_content", "reasoningDetails": [{"type": "reasoning.text", "text": "The user has loaded the \"greet\" skill which says the skill should begin my reply with the exact word MANGO. They're asking me to use $greet to say hi in three words.", "format": "unknown", "index": 0}]}}, "durable": {"aggregateID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84eea001Qzdmo2m7OyiV0d", "created": 1790747102954, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "assistantMessageID": "msg_0f0d840eb001q13lyy3XcbFJ2Z", "ordinal": 0, "delta": "MANGO Hi, how are you?"}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84eee001qT60pZ3zezyHCx", "created": 1790747102958, "type": "session.text.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "assistantMessageID": "msg_0f0d840eb001q13lyy3XcbFJ2Z", "ordinal": 0, "text": "MANGO Hi, how are you?"}, "durable": {"aggregateID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84ef5001PmQmUyZl28Z6Eo", "created": 1790747102965, "type": "session.step.streamed", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "assistantMessageID": "msg_0f0d840eb001q13lyy3XcbFJ2Z"}, "durable": {"aggregateID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "seq": 10, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84f08001Y1UmtfGsBjzcoM", "created": 1790747102985, "type": "session.step.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "assistantMessageID": "msg_0f0d840eb001q13lyy3XcbFJ2Z", "finish": "stop", "rawFinish": "stop", "cost": 0.0013473600000000001, "tokens": {"input": 9522, "output": 10, "reasoning": 41, "cache": {"read": 0, "write": 0}}, "snapshot": "5ecf8511987b833d27b391b832f802775c5ca199", "files": []}, "durable": {"aggregateID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84f11001ihquj086rc1t7e", "created": 1790747102993, "type": "session.usage.updated", "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "cost": 0.0013473600000000001, "tokens": {"input": 9522, "output": 10, "reasoning": 41, "cache": {"read": 0, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84f15001xEglV1k7oazIbn", "created": 1790747102997, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l"}, "durable": {"aggregateID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "seq": 12, "version": 1}}}} +{"type": "runtime_exit", "status": "success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/output.ts new file mode 100644 index 000000000000..be67fbe156da --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/output.ts @@ -0,0 +1,34 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_SKILL_PROMPT, + projectionFor, +} from "../shared.ts"; + +/** + * `$greet` names a workspace skill, so the prompt attaches it natively + * (`prompt{skills:[{id:"greet"}]}`, checked by the transcript) and the model + * follows it: the skill says to open with MANGO. + */ +export function assertOpenCode2SkillOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertUserMessagesInclude(projection, [OPENCODE2_SKILL_PROMPT]); + assertAssistantTextIncludes(projection, "MANGO"); + assert.deepEqual( + projection.providerTurns.map((turn) => turn.status), + ["completed"], + ); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson new file mode 100644 index 000000000000..c4efb89a327c --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson @@ -0,0 +1,84 @@ +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_switch", "metadata": {"source": "opencode2-spike/recordings/switch.ndjson", "capturedAt": "2026-09-28", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: a plan-mode turn after session.switchAgent(plan), then session.switchAgent(build) and session.switchModel before the next prompt. OpenCode queues its own Plan mode reminders. Replayed by OpenCode2OrchestratorV2.integration.test.ts. T3 reopens the session for the model change, so its session.get (answered as the recording's final read) sits before the second switch. The model list is the tool_call recording's plus the switched-to model, so no turn re-reads it."}} +{"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} +{"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}, {"id": "nemotron-3.5-lightning-free", "modelID": "nemotron-3.5-lightning-free", "providerID": "opencode", "family": "nemotron", "name": "Nemotron 3.5 Lightning Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} +{"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ede62001pWsxxlbE3OpMNp", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "agent.list", "frame": {"type": "agent.list", "input": {"location[directory]": "<any>"}}} +{"type": "emit_inbound", "label": "agent.list.response", "frame": {"type": "sdk.response", "operation": "agent.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "build", "name": "Build", "request": {"settings": {}, "headers": {}, "body": {}}, "description": "The default agent. Executes tools based on configured permissions.", "mode": "primary", "hidden": false, "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "external_directory", "resource": "*", "effect": "ask"}, {"action": "read", "resource": "*.env", "effect": "ask"}, {"action": "read", "resource": "*.env.*", "effect": "ask"}, {"action": "read", "resource": "*.env.example", "effect": "allow"}, {"action": "external_directory", "resource": "<home>/data/opencode/shell/*/*", "effect": "allow"}, {"action": "external_directory", "resource": "<home>/data/opencode/tool-output/*", "effect": "allow"}, {"action": "external_directory", "resource": "/tmp/opencode/*", "effect": "allow"}, {"action": "external_directory", "resource": "<home>/config/opencode/*", "effect": "allow"}, {"action": "question", "resource": "*", "effect": "allow"}]}, {"id": "plan", "name": "Plan", "request": {"settings": {}, "headers": {}, "body": {}}, "description": "Read-only agent for exploring the codebase and planning work before implementation.", "mode": "primary", "hidden": false, "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "external_directory", "resource": "*", "effect": "ask"}, {"action": "read", "resource": "*.env", "effect": "ask"}, {"action": "read", "resource": "*.env.*", "effect": "ask"}, {"action": "read", "resource": "*.env.example", "effect": "allow"}, {"action": "external_directory", "resource": "<home>/data/opencode/shell/*/*", "effect": "allow"}, {"action": "external_directory", "resource": "<home>/data/opencode/tool-output/*", "effect": "allow"}, {"action": "external_directory", "resource": "/tmp/opencode/*", "effect": "allow"}, {"action": "external_directory", "resource": "<home>/config/opencode/*", "effect": "allow"}, {"action": "question", "resource": "*", "effect": "allow"}, {"action": "edit", "resource": "*", "effect": "deny"}, {"action": "edit", "resource": "<home>/.opencode/plan/*", "effect": "allow"}, {"action": "external_directory", "resource": "<home>/.opencode/plan/*", "effect": "allow"}]}]}}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": "<any>"}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f14812197ffeCbw641wM2r3ZmP", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657355410, "updated": 1790657355410}, "title": "spike switch", "location": {"directory": "<work>"}}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ede92001NHXqv5r4f6UIAF", "created": 1790657355410, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "slug": "swift-moon", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "spike switch", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 0, "version": 1}}}} +{"type": "emit_inbound", "label": "project.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7edea1001PNWy24LBEXO40H", "created": 1790657355425, "type": "project.updated", "data": {"id": "global", "canonical": "<work>", "vcs": "git", "time": {"created": 1790656063718, "updated": 1790656063718, "active": 1790657355423}, "sandboxes": []}}}} +{"type": "expect_outbound", "label": "session.switchAgent", "frame": {"type": "session.switchAgent", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "agent": "plan"}}} +{"type": "emit_inbound", "label": "session.switchAgent.response", "frame": {"type": "sdk.response", "operation": "session.switchAgent", "data": null}} +{"type": "emit_inbound", "label": "session.agent.selected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7edeb2001MCL22KdHxuErJq", "created": 1790657355442, "type": "session.agent.selected", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "agent": "plan"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 1, "version": 1}}}} +{"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "text": "<any>"}}} +{"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7edec5001SuzE8LdQ6S95VC", "sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "time": {"created": 1790657355463}, "type": "user", "payload": {"text": "Create a file named plan_probe.txt containing HI using the write tool."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7edebb001yltYBTG8qR3qu5", "created": 1790657355452, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb7edeba001PfAzVPoYsGHgb2", "sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "item": {"type": "synthetic", "payload": {"text": "<system-reminder>\nYou are in Plan mode. Discuss the plan with the user directly in the conversation. Do not create or update plan files unless the user explicitly asks you to; when they do, write them only in:\n<home>/.opencode/plan\n\nDo not modify any other files or ask a subagent to do so.\n\nYou remain in Plan mode until the user switches agents. If the user asks you to implement changes, do not do so. Tell them they need to switch agents.\n</system-reminder>"}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7edec7001oak38mV3FQzoLz", "created": 1790657355463, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb7edec5001SuzE8LdQ6S95VC", "sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "item": {"type": "user", "payload": {"text": "Create a file named plan_probe.txt containing HI using the write tool."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 3, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7edecd001AVRgsNMX75Fnul", "created": 1790657355469, "type": "session.execution.started", "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7edee9001JREQjNgn33Hsvp", "created": 1790657355497, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "delta": {"core/environment": "b7bdab1b53fb2476c4215ff131874d3b50cccbd4763fb7b42cc5a775eb33f3dd", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 5, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7edeed001efthdBnjZqHc30", "created": 1790657355501, "type": "session.inbox.delivered", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "inboxID": "msg_0eb7edeba001PfAzVPoYsGHgb2"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7edeef001jy3c7BXKy7A4es", "created": 1790657355503, "type": "session.inbox.delivered", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "inboxID": "msg_0eb7edec5001SuzE8LdQ6S95VC"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ee57b001wlONu1n20AeZ1w", "created": 1790657357179, "type": "session.step.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "agent": "plan", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "started": 1790657355555}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ee582001YdBwW231fIAMAI", "created": 1790657357186, "type": "session.reasoning.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ee5ee00196mJjyHQF3L9Vx", "created": 1790657357294, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": "The user is asking me to create a file. But I'm in Plan mode, which says do not modify any other"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ee67b001xc9gRxFs4hTQX4", "created": 1790657357435, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " files. Hmm.\n\nThe system reminder says: \"You are in Plan mode"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ee70c0016V0mGHaz2ViN28", "created": 1790657357580, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": ". Discuss the plan with the user directly in the conversation. Do not create or update plan files"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ee7720013XCJFO26yBVhWu", "created": 1790657357682, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " unless the user explicitly asks you to; when they do, write them only"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ee807001q4cmx6RcKsZivq", "created": 1790657357831, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " in: <home>/.opencode/plan."}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.6", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ee878001v5Gv1IhSiwR8kw", "created": 1790657357944, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " Do not modify any other files.\"\n\nSo creating plan"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.7", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ee90400121MSWd4aogGDmI", "created": 1790657358084, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": "_probe.txt would be modifying a file outside the plan directory. The user"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.8", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ee9b2001ISHU6M6Uy529Ad", "created": 1790657358258, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " is asking me to do it. I should decline and explain that Plan mode doesn't"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.9", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eea350013yNDvP7qVfHuX3", "created": 1790657358389, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " allow writing files, and tell them to switch agents.\n\nI should"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.10", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eeacf0016KEUM6wISn7D9N", "created": 1790657358543, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " not use the write tool. Let me just tell them.\n\nKeep"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.11", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eeb01001mSaqA3UJ70hU9w", "created": 1790657358593, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " it concise."}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eeb020018aqvfkIU6pjO6Q", "created": 1790657358594, "type": "session.text.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 10, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eeb6a001hREQucOd3BjE4e", "created": 1790657358699, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": "I'm in Plan mode, so I can't create `plan_probe.txt` \u2014 this"}}}} +{"type": "emit_inbound", "label": "session.text.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eebf6001UVKmJhM8E7WBIK", "created": 1790657358838, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " mode is read-only for everything outside `<home>/."}}}} +{"type": "emit_inbound", "label": "session.text.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eec89001E4K26EAsIlsZ5X", "created": 1790657358985, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": "opencode/plan`.\n\nIf you want me to actually create the file, switch to"}}}} +{"type": "emit_inbound", "label": "session.text.delta.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eed34001i3Oi1HaxJAp84V", "created": 1790657359156, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " an agent with write access and I'll"}}}} +{"type": "emit_inbound", "label": "session.text.delta.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eed9d001RbBhC5sAtOIk2y", "created": 1790657359261, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " do it right away (it's a one-liner: write"}}}} +{"type": "emit_inbound", "label": "session.text.delta.6", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eee300018fNivPA43QfBCq", "created": 1790657359408, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " `HI` to `plan_probe.txt`).\n\nIf instead you'd"}}}} +{"type": "emit_inbound", "label": "session.text.delta.7", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eeeac001lX0VYp7BZx2w65", "created": 1790657359532, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " like to record this as a plan step, I can add it to a"}}}} +{"type": "emit_inbound", "label": "session.text.delta.8", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef37001XbzWzIhUA3NauA", "created": 1790657359671, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " plan file under `<home>/.opencode/plan` \u2014 just"}}}} +{"type": "emit_inbound", "label": "session.reasoning.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef570013xmMSymu5dLnxz", "created": 1790657359703, "type": "session.reasoning.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "text": "The user is asking me to create a file. But I'm in Plan mode, which says do not modify any other files. Hmm.\n\nThe system reminder says: \"You are in Plan mode. Discuss the plan with the user directly in the conversation. Do not create or update plan files unless the user explicitly asks you to; when they do, write them only in: <home>/.opencode/plan. Do not modify any other files.\"\n\nSo creating plan_probe.txt would be modifying a file outside the plan directory. The user is asking me to do it. I should decline and explain that Plan mode doesn't allow writing files, and tell them to switch agents.\n\nI should not use the write tool. Let me just tell them.\n\nKeep it concise.", "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.9", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef5b0014EYeyNrfSdpTFh", "created": 1790657359707, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "delta": " say the word."}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef5b0025ZAFCztsK5Bj0g", "created": 1790657359707, "type": "session.text.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "ordinal": 0, "text": "I'm in Plan mode, so I can't create `plan_probe.txt` \u2014 this mode is read-only for everything outside `<home>/.opencode/plan`.\n\nIf you want me to actually create the file, switch to an agent with write access and I'll do it right away (it's a one-liner: write `HI` to `plan_probe.txt`).\n\nIf instead you'd like to record this as a plan step, I can add it to a plan file under `<home>/.opencode/plan` \u2014 just say the word."}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 12, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef5e001L3fTopJph41usa", "created": 1790657359710, "type": "session.step.streamed", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 13, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef6d001C8PP5Da56CYjeE", "created": 1790657359725, "type": "session.step.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7edefc001mq6i61kzZ7WKyW", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 8910, "output": 271, "reasoning": 0, "cache": {"read": 384, "write": 0}}, "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": []}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 14, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef72001IkbH150zPUHAy5", "created": 1790657359730, "type": "session.usage.updated", "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "cost": 0, "tokens": {"input": 8910, "output": 271, "reasoning": 0, "cache": {"read": 384, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef75001z9Sy13uGDVqV3F", "created": 1790657359733, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 15, "version": 1}}}} +{"type": "expect_outbound", "label": "session.get", "frame": {"type": "session.get", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP"}}} +{"type": "emit_inbound", "label": "session.get.response", "frame": {"type": "sdk.response", "operation": "session.get", "data": {"data": {"id": "ses_f14812197ffeCbw641wM2r3ZmP", "projectID": "global", "agent": "plan", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 8910, "output": 271, "reasoning": 0, "cache": {"read": 384, "write": 0}}, "outcome": "succeeded", "time": {"created": 1790657355410, "updated": 1790657355463}, "title": "spike switch", "location": {"directory": "<work>"}, "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "subagent", "resource": "*", "effect": "deny"}]}}}} +{"type": "expect_outbound", "label": "session.update", "frame": {"type": "session.update", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}]}}} +{"type": "emit_inbound", "label": "session.update.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}} +{"type": "expect_outbound", "label": "session.switchAgent.2", "frame": {"type": "session.switchAgent", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "agent": "build"}}} +{"type": "emit_inbound", "label": "session.switchAgent.response.2", "frame": {"type": "sdk.response", "operation": "session.switchAgent", "data": null}} +{"type": "emit_inbound", "label": "session.agent.selected.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef80001lOHUbFIwlg7iap", "created": 1790657359744, "type": "session.agent.selected", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "agent": "build", "previous": "plan"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 16, "version": 1}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef830020JKd2UmFNPZ8vu", "created": 1790657359747, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb7eef83001M1o8rpSkbKzlf5", "sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "item": {"type": "synthetic", "payload": {"text": "<system-reminder>\nYou are NO LONGER in Plan mode. The previous Plan restrictions no longer apply. Any Plan mode instructions from earlier in this conversation are no longer active.\n</system-reminder>"}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 17, "version": 1}}}} +{"type": "expect_outbound", "label": "session.switchModel", "frame": {"type": "session.switchModel", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "model": {"providerID": "opencode", "id": "nemotron-3.5-lightning-free"}}}} +{"type": "emit_inbound", "label": "session.switchModel.response", "frame": {"type": "sdk.response", "operation": "session.switchModel", "data": null}} +{"type": "emit_inbound", "label": "session.model.selected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef8d001Yy5fmSDr6nQ8OS", "created": 1790657359757, "type": "session.model.selected", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "model": {"id": "nemotron-3.5-lightning-free", "providerID": "opencode"}, "previous": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 18, "version": 1}}}} +{"type": "expect_outbound", "label": "session.prompt.2", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "text": "<any>"}}} +{"type": "emit_inbound", "label": "session.prompt.response.2", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7eef95001lHN9WhCH6Q9l6w", "sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "time": {"created": 1790657359766}, "type": "user", "payload": {"text": "Reply exactly SWITCHED."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef96001Adumk9iclCkzRM", "created": 1790657359766, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb7eef95001lHN9WhCH6Q9l6w", "sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "item": {"type": "user", "payload": {"text": "Reply exactly SWITCHED."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 19, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef99001hHVHRZ1RWfV4ax", "created": 1790657359770, "type": "session.execution.started", "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 20, "version": 1}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eefa8001gja3RIjRsOT7ZU", "created": 1790657359784, "type": "session.inbox.delivered", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "inboxID": "msg_0eb7eef83001M1o8rpSkbKzlf5"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 21, "version": 1}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eefaa001ZXKgn50gq43u7J", "created": 1790657359786, "type": "session.inbox.delivered", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "inboxID": "msg_0eb7eef95001lHN9WhCH6Q9l6w"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 22, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f034d001Hht46pTlSuvYIu", "created": 1790657364813, "type": "session.step.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "agent": "build", "model": {"id": "nemotron-3.5-lightning-free", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7eefb300159LJRa4C2TSJKe", "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "started": 1790657359821}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 23, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f0351001RWymQMFoScGPJc", "created": 1790657364817, "type": "session.reasoning.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7eefb300159LJRa4C2TSJKe", "ordinal": 0, "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 24, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.12", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f03b8001by0uuVKRKYRPaq", "created": 1790657364920, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7eefb300159LJRa4C2TSJKe", "ordinal": 0, "delta": "The user wants me to"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.13", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f0420001am0i7tOuujxI0s", "created": 1790657365024, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7eefb300159LJRa4C2TSJKe", "ordinal": 0, "delta": " reply exactly with \"SWIT"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.14", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f04900012pY1HVZDe2zJCq", "created": 1790657365137, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7eefb300159LJRa4C2TSJKe", "ordinal": 0, "delta": "CHED\". That's straightforward"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.15", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f0539001Yhj2chnO0LrEsF", "created": 1790657365305, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7eefb300159LJRa4C2TSJKe", "ordinal": 0, "delta": ". I'll just output that"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.16", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f0559001rQf2HVeU616Js6", "created": 1790657365337, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7eefb300159LJRa4C2TSJKe", "ordinal": 0, "delta": " text."}}}} +{"type": "emit_inbound", "label": "session.text.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f055a001BjeO2oR2TxRKYZ", "created": 1790657365338, "type": "session.text.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7eefb300159LJRa4C2TSJKe", "ordinal": 0}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 25, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.10", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f05c2001bQsojwGOmoNOCU", "created": 1790657365442, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7eefb300159LJRa4C2TSJKe", "ordinal": 0, "delta": "SWITCHED"}}}} +{"type": "emit_inbound", "label": "session.reasoning.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f05d0001eb383A3neUGub1", "created": 1790657365456, "type": "session.reasoning.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7eefb300159LJRa4C2TSJKe", "ordinal": 0, "text": "The user wants me to reply exactly with \"SWITCHED\". That's straightforward. I'll just output that text.", "state": {"reasoningField": "reasoning_content", "reasoningDetails": [{"type": "reasoning.text", "text": "The user wants me to reply exactly with \"SWITCHED\". That's straightforward. I'll just output that text.", "format": "unknown", "index": 0}]}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 26, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f05d3001SIBxyvH6V5nT5u", "created": 1790657365459, "type": "session.text.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7eefb300159LJRa4C2TSJKe", "ordinal": 0, "text": "SWITCHED"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 27, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f05d6001Qb4RqNQERL2iTr", "created": 1790657365462, "type": "session.step.streamed", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7eefb300159LJRa4C2TSJKe"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 28, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f05e2001pYIbyM7bZNZp7c", "created": 1790657365474, "type": "session.step.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "assistantMessageID": "msg_0eb7eefb300159LJRa4C2TSJKe", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 10125, "output": 5, "reasoning": 26, "cache": {"read": 0, "write": 0}}, "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": []}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 29, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f05e5001iXXLxlHupgccC5", "created": 1790657365477, "type": "session.usage.updated", "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "cost": 0, "tokens": {"input": 19035, "output": 276, "reasoning": 26, "cache": {"read": 384, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7f05e6001qWep90mBDrIMBq", "created": 1790657365478, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 30, "version": 1}}}} +{"type": "runtime_exit", "status": "success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index 4b358fa63a71..ebb65c455a26 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -88,6 +88,10 @@ export const OPENCODE2_SUBAGENT_PROMPT = "Use the subagent tool to delegate to the explore subagent with the prompt: 'List the files in the current directory and report their names.' Wait for it, then summarize its answer in one line."; export const OPENCODE2_BACKGROUND_PROMPT = "Use the subagent tool with background enabled to delegate to the general subagent with the prompt: 'Run the shell command `sleep 20` with the bash tool and then reply exactly CHILD_OK.' As soon as it is launched, reply exactly PARENT_OK and end your turn without waiting for it."; +export const OPENCODE2_COMPACTION_FIRST_PROMPT = "Remember the codeword PAPAYA. Reply OK."; +export const OPENCODE2_COMPACTION_RECALL_PROMPT = "What was the codeword? One word."; +export const OPENCODE2_COMMAND_PROMPT = "/hello WORLD"; +export const OPENCODE2_SKILL_PROMPT = "Use $greet to say hi in three words."; export const TURN_INTERRUPT_PROMPT = "Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."; export const TURN_INTERRUPT_MID_TOOL_PROMPT = diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts index ab5b89e97833..9bdca1789515 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts @@ -13,6 +13,11 @@ import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; +import { + OPENCODE_2_RESPONSES, + OPENCODE_2_WORKSPACE_RESPONSES, + replayOpenCodeServer, +} from "../testFixtures/opencodeProbeResponses.ts"; import { OPENCODE_2_TEXT_GENERATION_UNSUPPORTED, OpenCodeDriver } from "./OpenCodeDriver.ts"; const serverStarts: Array<string> = []; @@ -62,10 +67,6 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { Effect.gen(function* () { serverStarts.length = 0; const instance = yield* create({}, noHttp); - - // The workspace snapshot falls back to the machine snapshot. - const workspace = yield* instance.snapshotForCwd!(process.cwd()); - assert.strictEqual(workspace.instanceId, instance.instanceId); const title = yield* Effect.flip( instance.textGeneration.generateThreadTitle({ cwd: process.cwd(), @@ -78,6 +79,34 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { }).pipe(Effect.scoped), ); + it.effect("lists an OpenCode 2 workspace's own skills and commands from its server", () => + Effect.gen(function* () { + serverStarts.length = 0; + const requested: Array<string> = []; + const server = replayOpenCodeServer( + { ...OPENCODE_2_RESPONSES, ...OPENCODE_2_WORKSPACE_RESPONSES }, + "secret", + requested, + ); + const instance = yield* create( + { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" }, + server, + ); + + const workspace = yield* instance.snapshotForCwd!("/work"); + assert.includeMembers( + workspace.skills.map((skill) => skill.name), + ["plum", "opencode"], + ); + assert.deepStrictEqual( + workspace.slashCommands.map((command) => command.name), + ["compact", "init", "review", "bee"], + ); + assert.include(requested, "/api/skill"); + assert.deepStrictEqual(serverStarts, []); + }).pipe(Effect.scoped), + ); + it.effect("answers capability reads without waiting on an unreachable server", () => Effect.gen(function* () { const hang = HttpClient.make(() => Effect.never); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index ef0025afa93a..4d8b524956ac 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -14,11 +14,13 @@ */ import { OpenCodeSettings, ProviderDriverKind, TextGenerationError } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -34,8 +36,11 @@ import { ProviderDriverError } from "../Errors.ts"; import { readOpenCodeGoUsageLimits } from "../Layers/openCodeUsageLimits.ts"; import { checkOpenCodeProviderStatus, + loadOpenCode2Workspace, makeOpenCode2ModelLoader, makePendingOpenCodeProvider, + openCode2CommandsToServerProviderSlashCommands, + openCode2SkillsToServerProviderSkills, openCodeSkillsToServerProviderSkills, openCodeCommandsToServerProviderSlashCommands, } from "../Layers/OpenCodeProvider.ts"; @@ -279,6 +284,42 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> ), ), ); + // A 2.x server lists skills and commands per directory, so one server + // answers every workspace. Its event stream says when a directory it had + // not served yet finished scanning. + const listOpenCode2Workspace = (cwd: string) => + openCode2Server.withConnection(({ client, events }) => + Effect.gen(function* () { + const location = { directory: cwd }; + const scanned = yield* Deferred.make<void>(); + const pending = new Set(["command.updated", "skill.updated"]); + const stream = yield* events.pipe(Effect.option); + if (stream._tag === "Some") { + yield* stream.value.pipe( + Stream.runForEach((event) => + "location" in event && + event.location?.directory === cwd && + pending.delete(event.type) && + pending.size === 0 + ? Deferred.succeed(scanned, undefined) + : Effect.void, + ), + Effect.ignore, + Effect.forkScoped, + ); + } + return yield* loadOpenCode2Workspace( + Effect.all( + { + skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)), + commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)), + }, + { concurrency: "unbounded" }, + ), + Deferred.await(scanned), + ); + }).pipe(Effect.scoped), + ); const serverOwner = yield* OpenCodeServerOwner.make({ binaryPath: effectiveConfig.binaryPath, directory: serverConfig.cwd, @@ -417,13 +458,31 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> accentColor, enabled, snapshot, - // OpenCode 2 has no per-workspace skill and command inventory yet, so a - // 2.x workspace shows the machine snapshot instead of starting a 1.x server. snapshotForCwd: (cwd) => !effectiveConfig.enabled ? snapshot.getSnapshot : byOpenCodeRuntime(runtimeProbe.get, { - v2: snapshot.getSnapshot, + v2: Effect.all([ + snapshot.getSnapshot, + listOpenCode2Workspace(cwd).pipe( + Effect.timeout("20 seconds"), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to list OpenCode commands and skills for '${cwd}'`, + cause, + }), + ), + ), + ]).pipe( + Effect.map(([machineSnapshot, { skills, commands }]) => ({ + ...machineSnapshot, + skills: openCode2SkillsToServerProviderSkills(skills), + slashCommands: openCode2CommandsToServerProviderSlashCommands(commands), + })), + ), v1: Effect.all([ snapshot.getSnapshot, loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 558eb3c6a790..e60515694ba5 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -3,6 +3,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -18,8 +19,12 @@ import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; import { checkOpenCodeProviderStatus, + loadOpenCode2Workspace, makeOpenCode2ModelLoader, type OpenCode2Model, + type OpenCode2Workspace, + openCode2CommandsToServerProviderSlashCommands, + openCode2SkillsToServerProviderSkills, openCodeCommandsToServerProviderSlashCommands, } from "./OpenCodeProvider.ts"; import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; @@ -562,6 +567,12 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { "auto", "full-access", ]); + // Plan mode runs as OpenCode's plan agent, and every session can compact. + NodeAssert.equal(snapshot.showInteractionModeToggle, true); + NodeAssert.deepEqual( + snapshot.slashCommands.map((command) => command.name), + ["compact"], + ); NodeAssert.deepEqual( snapshot.models.map((model) => model.slug), ["opencode/big-pickle", "opencode/space-bunny-free"], @@ -594,6 +605,11 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { NodeAssert.equal(snapshot.status, "error"); NodeAssert.equal(snapshot.message, "OpenCode could not load its model list."); + // A failed catalog read still leaves the session able to compact. + NodeAssert.deepEqual( + snapshot.slashCommands.map((command) => command.name), + ["compact"], + ); }), ); @@ -788,3 +804,70 @@ it.effect("keeps the last OpenCode 2 model list while a fresh server's stays emp NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); }).pipe(Effect.provide(TestClock.layer())), ); + +// What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29). +const builtinCommands = [ + { name: "init", description: "guided AGENTS.md setup" }, + { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" }, +]; +const builtinSkills = [ + { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" }, + { id: "report", name: "Report", path: "/builtin/report.md" }, +]; +const scanned: OpenCode2Workspace = { + commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }], + skills: [ + ...builtinSkills, + { + id: "greet", + name: "greet", + path: "/work/.opencode/skills/greet/SKILL.md", + description: "Greets the user with the secret word MANGO.", + }, + ], +}; + +it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () => + Effect.gen(function* () { + // A directory the server has not served yet lists no commands until its scan ends. + const reads: Array<OpenCode2Workspace> = [{ commands: [], skills: [] }, scanned]; + const scanEnded = yield* Deferred.make<void>(); + const load = yield* loadOpenCode2Workspace( + Effect.sync(() => reads.shift()!), + Deferred.await(scanEnded), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(reads.length, 1); + yield* Deferred.succeed(scanEnded, undefined); + NodeAssert.deepEqual(yield* Fiber.join(load), scanned); + }), +); + +it.effect("takes a served OpenCode 2 directory's first listing as is", () => + Effect.gen(function* () { + const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never); + NodeAssert.deepEqual(workspace, scanned); + }), +); + +it("lists an OpenCode 2 directory's skills by id and its commands after /compact", () => { + NodeAssert.deepEqual( + openCode2SkillsToServerProviderSkills(scanned.skills).map((skill) => [ + skill.name, + skill.displayName, + skill.path, + ]), + [ + ["greet", undefined, "/work/.opencode/skills/greet/SKILL.md"], + ["opencode", "OpenCode", "/builtin/opencode.md"], + ["report", "Report", "/builtin/report.md"], + ], + ); + NodeAssert.deepEqual( + openCode2CommandsToServerProviderSlashCommands([ + ...scanned.commands, + { name: "compact", description: "a workspace command named like T3's own" }, + ]).map((command) => command.name), + ["compact", "init", "review", "hello"], + ); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 731410cbe97e..96d8bf41bcd7 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -410,12 +410,78 @@ export const makeOpenCode2ModelLoader = <E>( ); }); +/** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */ +export interface OpenCode2Workspace { + readonly skills: ReadonlyArray<{ + readonly id: string; + readonly name: string; + readonly path: string; + readonly description?: string | undefined; + }>; + readonly commands: ReadonlyArray<{ + readonly name: string; + readonly description?: string | undefined; + }>; +} + +/** + * A server scans a directory it has not served yet in the background: its + * first read lists no commands at all (not even the built-ins), and + * `command.updated` plus `skill.updated` for the directory end the scan. A + * directory it already serves lists everything at once. `scanned` must be + * listening before `read` runs, since the read is what starts the scan. + */ +export const loadOpenCode2Workspace = <E>( + read: Effect.Effect<OpenCode2Workspace, E>, + scanned: Effect.Effect<void>, +) => + Effect.gen(function* () { + const first = yield* read; + if (first.commands.length > 0) return first; + yield* scanned.pipe(Effect.timeoutOption("10 seconds")); + return yield* read; + }); + +export function openCode2SkillsToServerProviderSkills( + skills: OpenCode2Workspace["skills"], +): ReadonlyArray<ServerProviderSkill> { + return skills + .map((skill) => { + const description = trimOptional(skill.description); + return { + name: skill.id, + path: skill.path, + enabled: true, + ...(skill.name === skill.id ? {} : { displayName: skill.name }), + ...(description ? { description, shortDescription: description } : {}), + }; + }) + .toSorted((left, right) => left.name.localeCompare(right.name)); +} + +export function openCode2CommandsToServerProviderSlashCommands( + commands: OpenCode2Workspace["commands"], +): ReadonlyArray<ServerProviderSlashCommand> { + const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; + const names = new Set([COMPACT_SLASH_COMMAND.name]); + for (const command of commands) { + const name = trimOptional(command.name); + if (!name || names.has(name)) continue; + names.add(name); + const description = trimOptional(command.description); + slashCommands.push({ name, ...(description ? { description } : {}) }); + } + return slashCommands; +} + /** * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised: - * OpenCode has no reviewer that approves routine actions. + * OpenCode has no reviewer that approves routine actions. Plan mode is its + * `plan` agent, so the composer's mode toggle drives it. */ const OPENCODE_2_PRESENTATION = { ...OPENCODE_PRESENTATION, + showInteractionModeToggle: true, supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"], } as const; @@ -468,6 +534,8 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( settings.customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ), + // Compaction does not depend on the model catalog. + slashCommands: [COMPACT_SLASH_COMMAND], probe: probe("error", "OpenCode could not load its model list."), }); } @@ -488,6 +556,8 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( enabled: true, checkedAt, models, + // Every session can compact; a workspace snapshot adds that directory's commands. + slashCommands: [COMPACT_SLASH_COMMAND], probe: result.value.length > 0 ? probe( diff --git a/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts b/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts index 84cf66e6715a..4ab85d22e550 100644 --- a/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts +++ b/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts @@ -30,6 +30,24 @@ export const OPENCODE_2_RESPONSES = { }, } satisfies Record<string, RecordedResponse>; +/** + * A 2.0.18 server's command and skill lists for a directory with one command + * (`.opencode/command/bee.md`) and one skill (`.opencode/skills/plum`), live on + * 2026-09-29. Skill contents are cut; machine-wide skills are dropped. + */ +export const OPENCODE_2_WORKSPACE_RESPONSES = { + "/api/command": { + status: 200, + contentType: "application/json", + body: '{"location":{"directory":"/work"},"data":[{"name":"init","description":"guided AGENTS.md setup"},{"name":"review","description":"review changes [commit|branch|pr], defaults to uncommitted"},{"name":"bee","description":"B command"}]}', + }, + "/api/skill": { + status: 200, + contentType: "application/json", + body: '{"location":{"directory":"/work"},"data":[{"id":"opencode","name":"OpenCode","description":"Use this skill for any question about OpenCode itself.","path":"/builtin/opencode.md","content":"# OpenCode"},{"id":"report","name":"Report","description":"Use when the user wants to report an opencode issue or bug.","path":"/builtin/report.md","content":"<!-- Built-in skill. -->"},{"id":"plum","name":"plum","description":"plum skill","path":"/work/.opencode/skills/plum/SKILL.md","content":"Say PLUM."}]}', + }, +} satisfies Record<string, RecordedResponse>; + export const OPENCODE_1_RESPONSES = { "/api/info": { status: 200, contentType: "text/html", body: SPA_HTML }, "/global/health": { @@ -43,10 +61,14 @@ export const OPENCODE_1_RESPONSES = { /** Replays a recorded server; credentials other than `password` (none when empty) get its 401. */ export function replayOpenCodeServer( - responses: typeof OPENCODE_1_RESPONSES | typeof OPENCODE_2_RESPONSES, + responses: + | typeof OPENCODE_1_RESPONSES + | typeof OPENCODE_2_RESPONSES + | (typeof OPENCODE_2_RESPONSES & typeof OPENCODE_2_WORKSPACE_RESPONSES), password: string, requestedPaths: Array<string> = [], ) { + const answers: Record<string, RecordedResponse | undefined> = responses; // Both versions decode Basic credentials as UTF-8 (checked live with `pässwörd` and `pass€word`). const expected = password ? `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}` @@ -55,9 +77,8 @@ export function replayOpenCodeServer( const path = new URL(request.url).pathname; requestedPaths.push(path); const recorded: RecordedResponse = - (path === "/api/info" || path === "/global/health") && request.headers.authorization === expected - ? responses[path] + ? (answers[path] ?? responses.unauthorized) : responses.unauthorized; return Effect.succeed( HttpClientResponse.fromWeb( From c9c1391eda8cdf282b01b6e142f4f99bb45db6ba Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:56:26 -0700 Subject: [PATCH 30/34] feat(server): OpenCode 2 text generation, MCP injection and restart reconciliation Carries pingdotgg/t3code#14386 oc2-9-textgen-mcp-restart unchanged, squashed to one commit with Julius Marminge's authorship. Drop this layer once it merges into codex-turn-mapping. --- apps/server/package.json | 1 + .../Adapters/OpenCode2AdapterV2.test.ts | 428 +++++++++++++++- .../Adapters/OpenCode2AdapterV2.testkit.ts | 80 ++- .../Adapters/OpenCode2AdapterV2.ts | 484 ++++++++++++++++-- .../Adapters/OpenCodeAdapterV2.testkit.ts | 12 +- ...penCode2OrchestratorV2.integration.test.ts | 330 +++++++++++- .../OpenCode2OrchestratorV2.live.test.ts | 213 +++++++- .../testkit/fixtures/index.ts | 20 + .../opencode_transcript.ndjson | 4 +- .../opencode_transcript.ndjson | 6 +- .../opencode_transcript.ndjson | 4 +- .../opencode2_fork/opencode_transcript.ndjson | 8 +- .../opencode_transcript.ndjson | 4 +- .../opencode_transcript.ndjson | 4 +- .../opencode_transcript.ndjson | 4 +- .../opencode_transcript.ndjson | 4 +- .../opencode2_resume_after_restart/input.ts | 14 + .../opencode_transcript.ndjson | 54 ++ .../opencode2_resume_after_restart/output.ts | 53 ++ .../opencode_transcript.ndjson | 4 +- .../opencode_transcript.ndjson | 30 +- .../opencode_transcript.ndjson | 4 +- .../opencode_transcript.ndjson | 4 +- .../opencode_transcript.ndjson | 8 +- .../opencode_transcript.ndjson | 8 +- .../testkit/fixtures/shared.ts | 4 + .../provider/Drivers/OpenCodeDriver.test.ts | 163 +++++- .../src/provider/Drivers/OpenCodeDriver.ts | 96 ++-- .../OpenCode2TextGeneration.fixture.ts | 371 ++++++++++++++ .../OpenCode2TextGeneration.test.ts | 266 ++++++++++ .../textGeneration/OpenCode2TextGeneration.ts | 221 ++++++++ .../textGeneration/OpenCodeTextGeneration.ts | 19 +- docs/user/providers-opencode.md | 17 + pnpm-lock.yaml | 3 + 34 files changed, 2759 insertions(+), 186 deletions(-) create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/opencode_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/output.ts create mode 100644 apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts create mode 100644 apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts create mode 100644 apps/server/src/textGeneration/OpenCode2TextGeneration.ts diff --git a/apps/server/package.json b/apps/server/package.json index fc8b83cbc68a..584fb71d667f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -37,6 +37,7 @@ "@opencode-ai/sdk": "^1.3.15", "@opencode/client": "2.0.18", "@opencode/protocol": "2.0.18", + "@opencode/schema": "2.0.18", "diff": "8.0.3", "effect": "catalog:", "jose": "catalog:", diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 41c607a4b26e..b7b1b800392d 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -6,6 +6,7 @@ import { assert, it } from "@effect/vitest"; import { CheckpointId, + EnvironmentId, MessageId, NodeId, ProviderInstanceId, @@ -35,6 +36,7 @@ import type { ProviderAdapterV2Event, ProviderAdapterV2SessionRuntime, } from "../ProviderAdapter.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as IdAllocator from "../IdAllocator.ts"; import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; @@ -66,8 +68,12 @@ const event = (type: string, data: Record<string, unknown>): ProviderReplayEntry }); const durable = { durable: { aggregateID: SESSION, seq: 1, version: 1 } }; -/** The rules T3 gives every session it runs. */ -const t3Rules = [{ action: "*", resource: "*", effect: "allow" }]; +/** The rules T3 gives every session it runs, with only this thread's own T3 MCP server allowed. */ +const mcpRules = [ + { action: "t3-code-*", resource: "*", effect: "deny" }, + { action: "t3-code-thread_opencode2-adapter_*", resource: "*", effect: "allow" }, +]; +const t3Rules = [{ action: "*", resource: "*", effect: "allow" }, ...mcpRules]; const sessionInfo = (overrides: Record<string, unknown> = {}) => ({ id: SESSION, permissions: t3Rules, @@ -103,8 +109,10 @@ const modelCatalog = { ], }; +/** The prompt id the recorded answer carries; a replay maps it to the one T3 chose. */ +const PROMPT_ID = "msg_0eb735d41001NJee1EvVePJAK5"; const promptAccepted = replyData("session.prompt", { - id: "msg_0eb735d41001NJee1EvVePJAK5", + id: PROMPT_ID, sessionID: SESSION, time: { created: 1790656601410 }, type: "user", @@ -120,6 +128,57 @@ const noOpenRequests: ReadonlyArray<ProviderReplayEntry> = [ replyData("session.form.list", []), ]; +/** + * A thread's first turn writes T3's instructions entry before it starts; the + * adapter only rewrites it when it changes, so later turns do not. + */ +const withInstructions = ( + entries: ReadonlyArray<ProviderReplayEntry>, +): ReadonlyArray<ProviderReplayEntry> => { + const first = entries.findIndex( + (entry) => + entry.type === "expect_outbound" && + typeof entry.frame === "object" && + entry.frame !== null && + "type" in entry.frame && + [ + "session.prompt", + "session.command", + "session.compact", + "command.list", + "skill.list", + ].includes(String(entry.frame.type)), + ); + // T3's MCP server is added before the entry that describes it. + const after = entries.findIndex( + (entry, index) => + index < first && + entry.type === "emit_inbound" && + typeof entry.frame === "object" && + entry.frame !== null && + "operation" in entry.frame && + entry.frame.operation === "mcp.add", + ); + const at = after < 0 ? first : after + 1; + return first < 0 + ? entries + : [ + ...entries.slice(0, at), + out("session.instructions.entry.put", { + sessionID: SESSION, + key: "t3-code", + value: "<any>", + }), + reply("session.instructions.entry.put", null), + ...entries.slice(at), + ]; +}; + +const openCode2ReplayRuntimeWithInstructions = ( + entries: ReadonlyArray<ProviderReplayEntry>, + options?: { readonly external?: boolean }, +) => openCode2ReplayRuntime(withInstructions(entries), options); + /** What every session sends when it opens: the event stream, then the model list. */ const opening: ReadonlyArray<ProviderReplayEntry> = [ out("event.subscribe"), @@ -206,6 +265,7 @@ const supervisedRules = [ { action: "edit", resource: "*", effect: "ask" }, { action: "external_directory", resource: "*", effect: "ask" }, ...buildPaths, + ...mcpRules, ]; // The first ask and question form the spike recorded (recordings/permission, question). @@ -247,7 +307,7 @@ const resumed = ( ) => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime( - [ + withInstructions([ ...opening, out("session.get", { sessionID: SESSION }), replyData("session.get", sessionInfo()), @@ -261,7 +321,7 @@ const resumed = ( ] : []), ...entries, - ], + ]), options?.external === undefined ? undefined : { external: options.external }, ); const thread = yield* runtime.resumeThread({ @@ -676,6 +736,36 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); + it.effect("leaves no background work pending once a reconnect finds its subagent gone", () => + Effect.gen(function* () { + // The subagent ends while the stream is down: its end, its report and + // the follow-up it starts are never seen. `session.active` lists nothing. + const { runtime, thread } = yield* resumed([ + ...backgroundLaunch(CHILD), + event("session.execution.started", { sessionID: CHILD }), + event("session.execution.succeeded", { sessionID: SESSION }), + { type: "runtime_exit", status: "success" } as const, + out("event.subscribe"), + out("session.active"), + replyData("session.active", {}), + ]); + const settled = yield* Deferred.make<string | null>(); + yield* runtime.events.pipe( + Stream.tap((event) => + event.type === "subagent.updated" && event.subagent.status === "interrupted" + ? Deferred.succeed(settled, event.subagent.result) + : Effect.void, + ), + Stream.runDrain, + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + assert.include((yield* Deferred.await(settled)) ?? "", "lost its connection to OpenCode"); + assert.isFalse(yield* runtime.hasPendingBackgroundWork!); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + }).pipe(Effect.scoped), + ); + it.effect("stops a nested background subagent's report on its own parent's session", () => Effect.gen(function* () { const MIDDLE = "ses_middle0000000000000000000"; @@ -1045,7 +1135,7 @@ describe("OpenCode2 adapter", () => { it.effect("gives a resumed session T3's rules when it was made with others", () => Effect.gen(function* () { - const runtime = yield* openCode2ReplayRuntime([ + const runtime = yield* openCode2ReplayRuntimeWithInstructions([ ...opening, out("session.get", { sessionID: SESSION }), // Made by an earlier build that denied subagents; resuming drops the deny. @@ -1073,7 +1163,7 @@ describe("OpenCode2 adapter", () => { it.effect("moves the session when the thread's worktree changed", () => Effect.gen(function* () { - const runtime = yield* openCode2ReplayRuntime([ + const runtime = yield* openCode2ReplayRuntimeWithInstructions([ ...opening, out("session.get", { sessionID: SESSION }), replyData("session.get", sessionInfo()), @@ -1094,7 +1184,7 @@ describe("OpenCode2 adapter", () => { "moves the session when a thread it resumes through ensureThread changed worktree", () => Effect.gen(function* () { - const runtime = yield* openCode2ReplayRuntime([ + const runtime = yield* openCode2ReplayRuntimeWithInstructions([ ...opening, out("session.get", { sessionID: SESSION }), replyData("session.get", sessionInfo()), @@ -1166,7 +1256,7 @@ describe("OpenCode2 adapter", () => { it.effect("stops the requests a session still waits on when a restarted T3 loads it", () => Effect.gen(function* () { // T3 restarted while the server kept waiting on an ask T3 no longer shows. - const runtime = yield* openCode2ReplayRuntime( + const runtime = yield* openCode2ReplayRuntimeWithInstructions( [ ...opening, out("session.get", { sessionID: SESSION }), @@ -1408,6 +1498,8 @@ describe("OpenCode2 adapter", () => { permissions: [ ...supervisedRules.slice(0, 3), { action: "shell", resource: "echo *", effect: "allow" }, + // A subagent's session may use its thread's T3 MCP server. + ...mcpRules, ], }), reply("session.update", null), @@ -1539,20 +1631,20 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); - it.effect("refuses a turn after the event stream is gone instead of prompting", () => + it.effect("refuses a turn once reconnecting to a lost event stream has given up", () => Effect.gen(function* () { - // The stream ends with no turn running; no prompt is expected after it. + // The server goes away with no turn running, and never comes back. const { runtime, thread } = yield* resumed([{ type: "runtime_exit", status: "success" }]); - // The runtime ends its own events once the lost stream is settled. - yield* runtime.events.pipe(Stream.runDrain); - const refused = yield* runtime - .startTurn(turnInput(thread)) - .pipe(Effect.flip, Effect.timeout("5 seconds")); + // Reconnecting retries a few times, seconds apart, then ends the runtime's events. + const drained = yield* runtime.events.pipe(Stream.runDrain, Effect.forkScoped); + yield* TestClock.adjust("1 minute"); + yield* Fiber.join(drained); + const refused = yield* runtime.startTurn(turnInput(thread)).pipe(Effect.flip); assert.equal(refused._tag, "ProviderAdapterEventStreamError"); - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); - it.effect("reports the session as failed after a lost stream settles its turns", () => + it.effect("fails the session once reconnecting to a lost event stream has given up", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ out("session.prompt", { sessionID: SESSION, text: "<any>" }), @@ -1562,17 +1654,313 @@ describe("OpenCode2 adapter", () => { // The runtime's event stream has one consumer. const events = yield* runtime.events.pipe(Stream.runCollect, Effect.forkScoped); yield* runtime.startTurn(turnInput(thread)); + yield* TestClock.adjust("1 minute"); const collected = yield* Fiber.join(events); + const terminal = collected.find((event) => event.type === "turn.terminal"); + assert.deepInclude(terminal, { status: "failed", threadDisposition: "broken" }); const last = collected.findLast((event) => event.type === "provider_session.updated"); assert.equal( last?.type === "provider_session.updated" ? last.providerSession.status : undefined, "error", ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + // A stream that drops and comes back: what the gap lost is read from the + // session's history, and the turn ends from the server's own state. Real + // servers only do this on a network blip, so these are driven here. + const reconnected = (active: Record<string, unknown>, ended?: "succeeded") => [ + { type: "runtime_exit", status: "success" } as const, + out("event.subscribe"), + out("session.active"), + replyData("session.active", active), + out("message.list", { sessionID: SESSION, order: "desc", limit: "50" }), + reply("message.list", { + data: [ + // OpenCode appends an `idle` item after each execution it ends. + ...(ended === undefined + ? [] + : [{ id: "msg_idle_gap", time: { created: 3 }, type: "idle", outcome: ended }]), + { + id: "msg_assistant_gap", + time: { created: 2 }, + type: "assistant", + agent: "build", + model: { id: "big-pickle", providerID: "opencode", variant: "default" }, + content: [ + { type: "text", text: "Sent while the stream was down." }, + { + type: "tool", + id: "call_gap", + name: "shell", + executed: true, + state: { + status: "completed", + input: { command: "echo GAP" }, + content: [{ type: "text", text: "GAP" }], + metadata: {}, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ], + finish: "stop", + }, + { + id: "msg_0eb735d41001NJee1EvVePJAK5", + time: { created: 1 }, + text: "hi", + type: "user", + }, + ], + cursor: {}, + }), + ]; + const turnItems = (collected: ReadonlyArray<ProviderAdapterV2Event>) => + collected.flatMap((event) => + event.type === "turn_item.updated" ? [`${event.turnItem.type}:${event.turnItem.status}`] : [], + ); + + it.effect("does not end a turn from an earlier turn's history when its prompt is not there", () => + Effect.gen(function* () { + // The stream drops before the turn's prompt reached the history: what the + // history holds is the previous turn's, and its `idle` is not this turn's end. + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "<any>" }), + promptAccepted, + { type: "runtime_exit", status: "success" } as const, + out("event.subscribe"), + out("session.active"), + replyData("session.active", {}), + out("message.list", { sessionID: SESSION, order: "desc", limit: "50" }), + reply("message.list", { + data: [ + { id: "msg_idle_earlier", time: { created: 3 }, type: "idle", outcome: "succeeded" }, + { + id: "msg_assistant_earlier", + time: { created: 2 }, + type: "assistant", + agent: "build", + model: { id: "big-pickle", providerID: "opencode", variant: "default" }, + content: [{ type: "text", text: "An earlier turn's answer." }], + finish: "stop", + }, + { id: "msg_user_earlier", time: { created: 1 }, text: "earlier", type: "user" }, + ], + cursor: {}, + }), + ]); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + assert.notInclude( + collected.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem.text] + : [], + ), + "An earlier turn's answer.", + ); + assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "interrupted" }); }).pipe(Effect.scoped), ); + + it.effect("ends a turn that finished while the stream was down with the server's outcome", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "<any>" }), + promptAccepted, + ...reconnected({}, "succeeded"), + ]); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + // The reply and the tool call it ran are shown before the turn ends. + assert.includeMembers(turnItems(collected), [ + "assistant_message:completed", + "command_execution:completed", + ]); + assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" }); + }).pipe(Effect.scoped), + ); + + it.effect("finishes a workspace command whose stream dropped before its inbox event", () => + Effect.gen(function* () { + // `session.command` answers 204 without the inbox item's id, and the + // stream drops before `session.inbox.enqueued` would have named it. + const { runtime, thread } = yield* resumed([ + out("command.list", "<any>"), + reply("command.list", { location: { directory: WORK }, data: [{ name: "hello" }] }), + // Where the history stood before the command. + out("message.list", { sessionID: SESSION, order: "desc", limit: "1" }), + reply("message.list", { + data: [ + { id: "msg_idle_before", time: { created: 1 }, type: "idle", outcome: "succeeded" }, + ], + cursor: {}, + }), + out("session.command", { sessionID: SESSION, name: "hello", text: "WORLD" }), + reply("session.command", null), + { type: "runtime_exit", status: "success" } as const, + out("event.subscribe"), + out("session.active"), + replyData("session.active", {}), + out("message.list", { sessionID: SESSION, order: "desc", limit: "50" }), + reply("message.list", { + data: [ + { id: "msg_idle_cmd", time: { created: 4 }, type: "idle", outcome: "succeeded" }, + { + id: "msg_assistant_cmd", + time: { created: 3 }, + type: "assistant", + agent: "build", + model: { id: "big-pickle", providerID: "opencode", variant: "default" }, + content: [{ type: "text", text: "HELLO WORLD" }], + finish: "stop", + }, + // The command's expanded template, the turn's own user item. + { + id: "msg_user_cmd", + time: { created: 2 }, + text: "Reply with exactly: HELLO WORLD", + type: "user", + }, + // The previous turn, which already ended before this one started. + { id: "msg_idle_before", time: { created: 1 }, type: "idle", outcome: "succeeded" }, + ], + cursor: {}, + }), + ]); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn({ + ...turnInput(thread), + message: { ...turnInput(thread).message, text: "/hello WORLD" }, + }); + const collected = yield* Fiber.join(events); + const texts = collected.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem.text] + : [], + ); + assert.include(texts, "HELLO WORLD"); + assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" }); + }).pipe(Effect.scoped), + ); + + it.live("returns the server it reconnected to once the session closes", () => + Effect.gen(function* () { + // A spawned server stops after it has no borrowers for a while, so a + // session must not keep holding the connection it reconnected with. + const borrowers = { current: 0 }; + yield* Effect.gen(function* () { + yield* openCode2ReplayRuntime( + [ + ...opening, + { type: "runtime_exit", status: "success" }, + out("event.subscribe"), + event("server.connected", {}), + ], + { borrowers }, + ); + // Reconnected: the dropped connection is returned and the new one is held. + yield* Effect.sleep("200 millis"); + assert.equal(borrowers.current, 1); + }).pipe(Effect.scoped); + assert.equal(borrowers.current, 0); + }), + ); + + it.effect("keeps a turn still running after a reconnect open for its next events", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "<any>" }), + promptAccepted, + ...reconnected({ [SESSION]: { type: "running" } }), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_assistant_after", + ordinal: 0, + text: "Arrived on the new stream.", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + const texts = collected.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem.text] + : [], + ); + assert.includeMembers(texts, [ + "Sent while the stream was down.", + "Arrived on the new stream.", + ]); + assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" }); + }).pipe(Effect.scoped), + ); + + it.effect( + "registers T3's MCP server for the thread alone and removes it when the thread unloads", + () => + Effect.gen(function* () { + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("environment:opencode2-adapter"), + threadId, + providerSessionId: "mcp:opencode2-adapter", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:3773/mcp", + authorizationHeader: "Bearer thread-credential", + browserToolsAvailable: false, + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)), + ); + const server = "t3-code-thread_opencode2-adapter"; + const { runtime, thread } = yield* resumed([ + // Registered for the session's directory under the thread's own name; + // the session's rules allow only this name's tools (see `t3Rules`). + out("mcp.add", { + server, + "location[directory]": WORK, + config: { + type: "remote", + url: "http://127.0.0.1:3773/mcp", + headers: { Authorization: "Bearer thread-credential" }, + oauth: false, + }, + }), + reply("mcp.add", null), + out("session.prompt", { sessionID: SESSION, text: "<any>" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + out("mcp.remove", { server, "location[directory]": WORK }), + reply("mcp.remove", null), + ]); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + yield* runtime.unloadThread!({ providerThread: thread }); + }).pipe(Effect.scoped), + ); + it.effect("reads user and assistant text from the session's message list", () => Effect.gen(function* () { - const runtime = yield* openCode2ReplayRuntime([ + const runtime = yield* openCode2ReplayRuntimeWithInstructions([ ...opening, out("message.list", { sessionID: SESSION, order: "asc", limit: "100" }), reply("message.list", history), @@ -1597,7 +1985,7 @@ describe("OpenCode2 adapter", () => { it.effect("ends a turn before re-reading a model list that never answers", () => Effect.gen(function* () { // The session opened before the catalog loaded, so the turn's model has no window. - const runtime = yield* openCode2ReplayRuntime([ + const runtime = yield* openCode2ReplayRuntimeWithInstructions([ out("event.subscribe"), out("model.list", "<any>"), reply("model.list", { location: { directory: WORK }, data: [] }), diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index 5720d1375c48..fcc522930306 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -67,6 +67,22 @@ const operationOf = ( if (method === "POST" && path === "/api/session") return { type: "session.create", input: body }; if (method === "GET" && path === "/api/session/active") return { type: "session.active" }; if (method === "GET" && path === "/api/command") return { type: "command.list", input: query }; + const mcp = /^\/api\/experimental\/mcp\/([^/]+)$/.exec(path); + if (mcp !== null && method === "PUT") { + return { type: "mcp.add", input: { server: mcp[1], ...query, ...(body as object) } }; + } + if (mcp !== null && method === "DELETE") { + return { type: "mcp.remove", input: { server: mcp[1], ...query } }; + } + const entry = /^\/api\/experimental\/session\/([^/]+)\/instructions\/entries\/([^/]+)$/.exec( + path, + ); + if (entry !== null && method === "PUT") { + return { + type: "session.instructions.entry.put", + input: { sessionID: entry[1], key: entry[2], ...(body as object) }, + }; + } if (method === "GET" && path === "/api/skill") return { type: "skill.list", input: query }; if (session !== null) { const [, sessionID, rest = ""] = session; @@ -76,6 +92,7 @@ const operationOf = ( ); const input = { sessionID, ...query, ...Object.fromEntries(fields) }; if (method === "GET" && rest === "") return { type: "session.get", input }; + if (method === "DELETE" && rest === "") return { type: "session.remove", input }; if (method === "PATCH" && rest === "") return { type: "session.update", input }; if (method === "POST" && rest === "/prompt") return { type: "session.prompt", input }; if (method === "POST" && rest === "/command") return { type: "session.command", input }; @@ -116,7 +133,13 @@ const operationOf = ( return { type: `${method} ${path}`, input: { ...query, body } }; }; -/** An `HttpClient` that answers every request from the transcript. */ +/** + * An `HttpClient` that answers every request from the transcript. A successful + * `runtime_exit` ends the event stream: the server has stopped. When entries + * follow it, the server was restarted and the next `event.subscribe` opens its + * new stream; a transcript that ends there stays down, so every later request + * fails to connect as it would against a stopped server. + */ const replayHttpClient = ( controller: OpenCodeReplayController, replayGate: ProviderReplayGate | undefined, @@ -124,6 +147,9 @@ const replayHttpClient = ( HttpClient.make((request, url) => Effect.tryPromise({ try: async () => { + if (controller.exited && controller.finished) { + throw new Error("The replayed OpenCode server has exited."); + } const raw = request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) @@ -140,6 +166,7 @@ const replayHttpClient = ( if (operation.type !== "event.subscribe") await controller.untilInboundDelivered(); await controller.expectOutbound(operation); if (operation.type === "event.subscribe") { + controller.exited = false; const encoder = new TextEncoder(); const events = controller.events(undefined, replayGate?.beforeEmit); const frames = events[Symbol.asyncIterator](); @@ -173,10 +200,18 @@ const replayHttpClient = ( }).pipe(Effect.map((response) => HttpClientResponse.fromWeb(request, response))), ); -/** The 2.x adapter over a replayed server, checking at scope close that the transcript ran out. */ -const makeReplayAdapter = ( +/** + * An OpenCode 2 server that answers from `transcript`, checking at scope close + * that the transcript ran out. + */ +export const replayServer = ( transcript: ProviderReplayTranscript, - options?: { readonly external?: boolean; readonly replayGate?: ProviderReplayGate }, + options?: { + readonly external?: boolean; + readonly replayGate?: ProviderReplayGate; + /** Counts the connections currently lent out, as the server owner's borrowers. */ + readonly borrowers?: { current: number }; + }, ) => Effect.gen(function* () { const controller = new OpenCodeReplayController(transcript); @@ -198,11 +233,38 @@ const makeReplayAdapter = ( version: transcript.version, external: options?.external ?? false, }; + const borrowers = options?.borrowers; + return OpenCode2Server.OpenCode2Server.of({ + withConnection: (use) => + borrowers === undefined + ? use(connection) + : Effect.acquireUseRelease( + Effect.sync(() => { + borrowers.current += 1; + }), + () => use(connection), + () => + Effect.sync(() => { + borrowers.current -= 1; + }), + ), + }); + }); + +/** The 2.x adapter over a replayed server. */ +const makeReplayAdapter = ( + transcript: ProviderReplayTranscript, + options?: { + readonly external?: boolean; + readonly replayGate?: ProviderReplayGate; + /** Counts the connections currently lent out, as the server owner's borrowers. */ + readonly borrowers?: { current: number }; + }, +) => + Effect.gen(function* () { + const server = yield* replayServer(transcript, options); return yield* OpenCode2AdapterV2.make(ProviderInstanceId.make("opencode")).pipe( - Effect.provideService( - OpenCode2Server.OpenCode2Server, - OpenCode2Server.OpenCode2Server.of({ withConnection: (use) => use(connection) }), - ), + Effect.provideService(OpenCode2Server.OpenCode2Server, server), ); }); @@ -228,7 +290,7 @@ function makeRegistryLayer( */ export const openCode2ReplayRuntime = ( entries: ReadonlyArray<ProviderReplayEntry>, - options?: { readonly external?: boolean }, + options?: { readonly external?: boolean; readonly borrowers?: { current: number } }, ) => Effect.gen(function* () { const adapter = yield* makeReplayAdapter( diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index f89ec8c46ee1..f837aa029572 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -34,6 +34,7 @@ import { Skill, type OpenCodeEvent, } from "@opencode/client/effect"; +import { Mcp } from "@opencode/schema/mcp"; import { isOrchestrationV2WorkActive, type OrchestrationV2AppThread, @@ -61,6 +62,7 @@ import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Schedule from "effect/Schedule"; +import * as Scope from "effect/Scope"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; @@ -72,7 +74,9 @@ import { parseOpenCodeModelSlug, type OpenCodeRuntimeError, } from "../../provider/opencodeRuntime.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; +import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { causeErrorTag } from "@t3tools/shared/observability"; @@ -221,6 +225,15 @@ interface ActiveTurn { }; steps: number; lastStep: Tokens | undefined; + /** + * The history item this turn's execution follows, for a turn with no prompt + * id of T3's: the session's newest item before a `/name` command (null when + * the history was empty), since `session.command` takes no id and answers + * without one; or the report a continuation turn's execution answers. A + * reconnect backfills that turn from everything after this. Other turns + * backfill from their own prompt's id (`nativeTurnRef`). + */ + before: string | null | undefined; /** The compaction running in this turn, `/compact` or OpenCode's own when the context fills. */ compaction: { readonly nativeId: string; readonly startedAt: DateTime.Utc } | undefined; compactions: number; @@ -285,6 +298,8 @@ interface Wake { readonly detail: string | null; /** Stopped, or taken by a user turn: the continuation it asked for is not needed. */ dropped: boolean; + /** The last report it delivered (inbox ids are history ids): its execution follows it. */ + readonly after: string | undefined; } interface OpenBlock { @@ -343,7 +358,12 @@ interface ThreadState { */ readonly reports: Map< string, - { readonly childId: string; readonly report: BackgroundWorkReport; readonly text: string } + { + readonly inboxId: string; + readonly childId: string; + readonly report: BackgroundWorkReport; + readonly text: string; + } >; /** * Background subagent sessions T3 stopped. OpenCode wakes the parent to @@ -355,6 +375,11 @@ interface ThreadState { * inbox, where the next prompt would deliver them first, so it cancels them. */ readonly strandedSteers: Set<string>; + /** T3's MCP server as registered for this thread, and the instructions entry sent with it. */ + mcp: + | { readonly name: string; readonly directory: string; readonly credential: string } + | undefined; + instructions: string | undefined; } type TurnTerminal = @@ -400,10 +425,33 @@ const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resour * output, the plan agent's plan directory), which the blanket rules here would * otherwise override; `grants` are "Always allow this session" answers. */ +/** + * T3's MCP server is registered per directory, not per session, so each thread + * gets its own `t3-code-<thread>` entry with its own credential. OpenCode names + * an MCP tool's permission `<server>_<tool>` (non-alphanumerics become `_`). + */ +const t3McpServerName = (threadId: string) => + `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`; + +/** + * The rules that keep T3's MCP servers to their own thread, after the mode's: + * the last matching rule wins, so every thread's T3 server is denied and then + * this thread's own is allowed again, in every mode. A subagent's session + * inherits the thread's. + */ +const mcpRules = (threadId: string | null): ReadonlyArray<Rule> => + threadId === null + ? [] + : [ + { action: "t3-code-*", resource: "*", effect: "deny" }, + { action: `${t3McpServerName(threadId)}_*`, resource: "*", effect: "allow" }, + ]; + const sessionRules = ( policy: RulesPolicy, paths: ReadonlyArray<Rule>, grants: ReadonlyArray<Rule>, + threadId: string | null, ): ReadonlyArray<Rule> => [ ...(policy.runtimeMode === "full-access" ? [rule("*", "allow")] @@ -417,6 +465,7 @@ const sessionRules = ( // are never denied: the free tier refuses sessions whose rules deny them. ...(policy.interactionMode === "plan" ? [rule("edit", "deny")] : []), ...paths, + ...mcpRules(threadId), ]; const sameRules = (left: ReadonlyArray<Rule> | undefined, right: ReadonlyArray<Rule>) => @@ -574,6 +623,17 @@ const isWakeTurn = (turn: OrchestrationV2ProviderTurn) => turn.nativeTurnRef?.nativeId?.includes(":wake:") === true; const INTERRUPT_TIMEOUT = "10 seconds"; +/** The session instructions entry T3 writes its per-turn system prompt to. */ +const INSTRUCTIONS_KEY = "t3-code"; +/** A lost event stream is resubscribed this many times, this far apart, before the session breaks. */ +const RECONNECT_ATTEMPTS = 5; +const RECONNECT_DELAY = "2 seconds"; +const RECONCILE_TIMEOUT = "15 seconds"; +/** How long a new turn waits for a reconnect in progress. */ +const RECONNECT_WAIT = "30 seconds"; +/** A background subagent's result when its end was lost with the event stream. */ +const LOST_BACKGROUND = + "T3 Code lost its connection to OpenCode while this subagent ran, so its result is not shown."; /** How long a turn waits on the directory's commands or skills before sending the text as is. */ const INVENTORY_TIMEOUT = "5 seconds"; const ACTIVE_CHECK_TIMEOUT = "5 seconds"; @@ -738,11 +798,33 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // Context windows by `provider/model`, from the latest `/api/model` read. const contextWindows = new Map<string, number>(); + /** + * Lends the instance's server to a session until its scope closes. A spawned + * server that died is started again on the next borrow. + */ + const borrow = Effect.gen(function* () { + const lent = yield* Deferred.make<OpenCode2Server.OpenCode2Connection, OpenCodeRuntimeError>(); + yield* server + .withConnection((connection) => + Deferred.succeed(lent, connection).pipe(Effect.andThen(Effect.never)), + ) + .pipe( + Effect.catch((error) => Deferred.fail(lent, error)), + Effect.forkScoped, + ); + return yield* Deferred.await(lent); + }); + const openSession = Effect.fn("OpenCode2Adapter.openSession")(function* ( input: Parameters<ProviderAdapter.ProviderAdapterV2Shape["openSession"]>[0], - connection: OpenCode2Server.OpenCode2Connection, + initial: { + readonly connection: OpenCode2Server.OpenCode2Connection; + readonly scope: Scope.Closeable; + }, ) { - const { client } = connection; + let connection = initial.connection; + // Replaced when the session reconnects to a restarted server. + let client = connection.client; const sessionScope = yield* Effect.scope; const now = yield* DateTime.now; let session: OrchestrationV2ProviderSession = { @@ -800,6 +882,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr reports: new Map(), stoppedChildren: new Set(), strandedSteers: new Set(), + mcp: undefined, + instructions: undefined, }); /** The thread whose session started this one, through any nesting. */ @@ -1098,6 +1182,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0 }, steps: 0, lastStep: undefined, + before: undefined, compaction: undefined, compactions: 0, interrupted: false, @@ -2105,6 +2190,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr reports: delivered.map((entry) => entry.report), detail: delivered.length === 0 ? null : delivered.map((entry) => entry.text).join("\n\n"), dropped: false, + after: delivered.at(-1)?.inboxId, }; state.wakes.push(wake); yield* Effect.logInfo("OpenCode started a turn on its own; asking for a continuation.", { @@ -2129,6 +2215,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); const outcome = reportOutcome(stringField(payload.metadata, "state")); state.reports.set(inboxId, { + inboxId, childId, text: payload.text, report: { @@ -2376,12 +2463,17 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return yield* onTurnEvent(state, turn, event); }); - // The stream is the only terminal signal, so a lost stream settles every - // running turn and breaks the session: T3 reopens it for the next turn. - // Set before the turns are settled, so a turn starting meanwhile sees it. + // The stream is the only terminal signal, and it is volatile: events sent + // while it is down are gone, and a restarted server never ends the + // execution it lost. So a lost stream reconnects, then reconciles each + // running turn from the server's own state (see `reconcile`). Only when + // reconnecting keeps failing are the turns failed and the session broken, + // so T3 reopens it. Set first, so a turn starting meanwhile waits or refuses. let streamFailure: string | undefined; + let reconnected = yield* Deferred.make<void>(); const failAll = Effect.fnUntraced(function* (message: string) { streamFailure = message; + yield* Deferred.succeed(reconnected, undefined); for (const state of threads.values()) { const failure = makeProviderFailure({ message, class: "transport_error" }); yield* finishTurn(state, { status: "failed", failure }, "broken"); @@ -2395,17 +2487,190 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* setSessionStatus("error", message); yield* Queue.end(events); }); - // Subscribed before any session or prompt call, so no event of theirs is missed. - const stream = yield* connection.events; - yield* stream.pipe( - Stream.runForEach((event) => lock.withPermit(handleEvent(event))), - Effect.matchCauseEffect({ - onSuccess: () => failAll("The OpenCode event stream ended."), - onFailure: () => failAll("The OpenCode event stream failed."), - }), - Effect.forkScoped, + /** + * Emits what a running turn missed while the stream was down, from the + * session's history since the turn began: text, reasoning and tools, each + * under the same native id its live events would have used, so nothing + * already shown is duplicated. A turn begins at its prompt, whose id T3 + * chose (`nativeTurnRef`), or after `before` for a `/name` command or a + * continuation. + * Returns how that history says the turn's execution ended: the `idle` item + * OpenCode appends after each one, or undefined when there is none after + * the turn's start. + */ + const backfill = Effect.fnUntraced(function* (sessionId: string, state: ThreadState) { + const turn = state.active; + if (turn === undefined) return undefined; + const promptId = promptOf(turn.providerTurn); + const { before } = turn; + if (promptId === undefined && before === undefined) return undefined; + const start = before !== undefined ? before : promptId; + const read = yield* paginate( + { sessionID: Session.ID.make(sessionId), order: "desc" as const, limit: 50 }, + client.message.list, + ).pipe( + Stream.takeUntil((message) => message.id === start), + Stream.runCollect, + ); + // Without its start the history holds only earlier turns (the stream + // dropped before the prompt landed): none of it is this turn's. A null + // `before` is a command that started on an empty history. + const found = start === null || read.at(-1)?.id === start; + if (!found) return undefined; + const recent = before !== undefined && before !== null ? read.slice(0, -1) : read; + const idle = recent.find((message) => message.type === "idle"); + for (const message of recent.toReversed()) { + if (message.type !== "assistant" || state.active !== turn) continue; + const ordinals = { text: 0, reasoning: 0 }; + for (const part of message.content) { + if (part.type === "text" || part.type === "reasoning") { + const block = { assistantMessageID: message.id, ordinal: ordinals[part.type]++ }; + yield* emitText(state, turn, block, part.type, () => part.text, true); + continue; + } + if (part.type !== "tool") continue; + if (!turn.tools.has(part.id)) { + turn.tools.set(part.id, { name: part.name, input: {} }); + turn.startedAt.set(part.id, yield* DateTime.now); + } + const tool = turn.tools.get(part.id)!; + if (typeof part.state.input === "object" && part.state.input !== null) { + tool.input = part.state.input as Record<string, unknown>; + } + if (part.state.status === "completed") { + yield* emitTool(state, turn, part.id, "completed", { + output: textOf(part.state.content), + metadata: part.state.metadata, + }); + turn.tools.delete(part.id); + } else if (part.state.status === "error") { + yield* emitTool(state, turn, part.id, "failed", { + output: part.state.error.message, + metadata: part.state.metadata, + }); + turn.tools.delete(part.id); + } else { + yield* emitTool(state, turn, part.id, "running"); + } + } + } + return idle === undefined ? undefined : { outcome: idle.outcome }; + }); + + /** + * Settles each thread turn that was running when the stream dropped, from + * the server: a session still running keeps its turn (its next events + * arrive on the new stream); one that stopped ends the turn with the + * outcome of the `idle` item after the turn's start. A restarted server + * writes no such item for the execution it lost, and `session.outcome` is + * still the previous execution's, so no `idle` means the turn was + * interrupted. A continuation turn is settled the same way, from the report + * its execution answers. A turn still holding for an undelivered steer only + * ends once the session is idle. + * + * What the stream carried outside a turn is not read back: a background + * subagent's report, a follow-up OpenCode started on its own, or one held + * for its continuation turn. So background work is settled, not resumed: a + * subagent whose session stopped ends as interrupted, and a thread's held + * follow-ups and queued reports are dropped, so nothing waits on an event + * that already went by. + */ + const reconcile = Effect.gen(function* () { + const running = [...threads].filter( + ([, state]) => state.active !== undefined && state.subagent === undefined, + ); + const background = [...threads.values()].filter( + (state) => state.subagent === undefined && hasBackground(state), + ); + if (running.length === 0 && background.length === 0 && busy.size === 0) return; + const active = yield* client.session.active(); + // An execution that ended while the stream was down never said so. + for (const sessionId of busy) if (!(sessionId in active)) busy.delete(sessionId); + for (const [sessionId, state] of running) { + const turn = state.active; + if (turn === undefined) continue; + const ended = yield* backfill(sessionId, state); + if (sessionId in active || state.active !== turn) continue; + yield* finishTurn( + state, + ended?.outcome === "succeeded" + ? { status: turn.interrupted ? "interrupted" : "completed" } + : ended?.outcome === "failed" + ? { + status: "failed", + failure: makeProviderFailure({ + message: + "OpenCode ended the turn with an error while T3 Code was reconnecting.", + class: "provider_error", + }), + } + : { status: "interrupted" }, + ); + } + for (const state of background) { + // A subagent still running keeps its call; its end arrives on the new stream. + for (const call of runningCalls(state)) { + const child = call.child?.sessionId; + if (child === undefined || !(child in active)) { + yield* settleCall(call, "interrupted", LOST_BACKGROUND); + } + } + for (const wake of state.wakes.splice(0)) wake.dropped = true; + state.reports.clear(); + } + }); + + /** + * Subscribes again, on a restarted server if the old one is gone, and + * reconciles. Retried a few times; the caller fails everything after that. + */ + const reconnect = Effect.gen(function* () { + const scope = yield* Scope.make(); + const next = yield* borrow.pipe( + Effect.provideService(Scope.Scope, scope), + Effect.tapError(() => Scope.close(scope, Exit.void)), + ); + const stream = yield* next.events.pipe(Effect.tapError(() => Scope.close(scope, Exit.void))); + const previous = currentScope; + connection = next; + client = next.client; + currentScope = scope; + yield* Scope.close(previous, Exit.void); + // A restarted server forgot T3's MCP servers; the next turn adds them again. + for (const state of threads.values()) state.mcp = undefined; + yield* lock.withPermit(reconcile).pipe(Effect.timeout(RECONCILE_TIMEOUT)); + return stream; + }).pipe( + Effect.retry({ times: RECONNECT_ATTEMPTS - 1, schedule: Schedule.spaced(RECONNECT_DELAY) }), ); + let currentScope = initial.scope; + // The borrow in use when the session closes is returned with it, so a + // spawned server can still reach its idle shutdown. + yield* Effect.addFinalizer(() => Scope.close(currentScope, Exit.void)); + const follow = (stream: Stream.Stream<OpenCode2StreamEvent, unknown>): Effect.Effect<void> => + stream.pipe( + Stream.runForEach((event) => lock.withPermit(handleEvent(event))), + Effect.exit, + Effect.flatMap(() => + Effect.gen(function* () { + streamFailure = "The OpenCode event stream was lost. Reconnecting."; + yield* Effect.logWarning("Lost the OpenCode event stream; reconnecting."); + const next = yield* reconnect.pipe(Effect.option); + if (next._tag === "None") { + return yield* failAll("The OpenCode event stream was lost and could not reconnect."); + } + streamFailure = undefined; + const done = reconnected; + reconnected = yield* Deferred.make<void>(); + yield* Deferred.succeed(done, undefined); + return yield* follow(next.value); + }), + ), + ); + // Subscribed before any session or prompt call, so no event of theirs is missed. + yield* follow(yield* connection.events).pipe(Effect.forkScoped); + // A server T3 did not start keeps running after T3 stops, so stop the turns // it would otherwise finish unseen. A spawned server stops with its owner. if (connection.external) { @@ -2423,23 +2688,23 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // Context windows come from the server's model list, read when the session // opens and again after a turn whose model had none yet. - const readModels = client.model - .list({ location: { directory: session.cwd ?? serverConfig.cwd } }) - .pipe( - Effect.timeout("5 seconds"), - Effect.tap((models) => - Effect.sync(() => { - for (const model of models.data) { - // A model's input limit, when it has one, is its real headroom. - contextWindows.set( - `${model.providerID}/${model.id}`, - model.limit.input ?? model.limit.context, - ); - } - }), - ), - Effect.ignore({ log: true }), - ); + const readModels = Effect.suspend(() => + client.model.list({ location: { directory: session.cwd ?? serverConfig.cwd } }), + ).pipe( + Effect.timeout("5 seconds"), + Effect.tap((models) => + Effect.sync(() => { + for (const model of models.data) { + // A model's input limit, when it has one, is its real headroom. + contextWindows.set( + `${model.providerID}/${model.id}`, + model.limit.input ?? model.limit.context, + ); + } + }), + ), + Effect.ignore({ log: true }), + ); yield* readModels; // Each agent's own path allows, by the directory they were listed for. @@ -2490,18 +2755,25 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const rulesFor = Effect.fnUntraced(function* ( thread: Pick<ThreadState, "directory" | "agent" | "grants">, policy: RulesPolicy, + appThreadId: string | null, ) { const plan = policy.interactionMode === "plan"; const paths = policy.runtimeMode === "full-access" && !plan ? [] : yield* pathsFor(thread.directory, plan ? [thread.agent, "plan"] : [thread.agent]); - return sessionRules(policy, paths, policy.runtimeMode === "full-access" ? [] : thread.grants); + return sessionRules( + policy, + paths, + policy.runtimeMode === "full-access" ? [] : thread.grants, + appThreadId, + ); }); /** Writes the session's rules for `policy` when they differ from what it has. */ const writeRules = Effect.fnUntraced(function* (state: ThreadState, policy: RulesPolicy) { - const rules = yield* rulesFor(state, policy); + // A subagent's session may use its thread's T3 server, the root's. + const rules = yield* rulesFor(state, policy, rootOf(state).providerThread.appThreadId); if (!sameRules(state.rules, rules)) { yield* client.session.update({ sessionID: Session.ID.make(state.sessionId), @@ -2666,18 +2938,103 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }; }); - const promptText = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => { - const text = providerMessageTextWithAttachmentPaths({ + const promptText = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => + providerMessageTextWithAttachmentPaths({ text: turnInput.message.text, attachments: turnInput.message.attachments, attachmentsDir: serverConfig.attachmentsDir, }).trim(); - const instructions = buildRuntimeInstructions({ - harness: "OpenCode", - model: turnInput.modelSelection.model, + + const removeMcp = (mcp: { readonly name: string; readonly directory: string }) => + Effect.suspend(() => + client.mcp.remove({ server: mcp.name, location: { directory: mcp.directory } }), + ).pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })); + + // T3's MCP registrations outlive a session only on an external server; a + // spawned one forgets them when it stops. + yield* Effect.addFinalizer(() => + Effect.forEach( + [...threads.values()].flatMap((state) => (state.mcp === undefined ? [] : [state.mcp])), + removeMcp, + { concurrency: 8, discard: true }, + ), + ); + + /** + * T3's MCP server for this thread, and the per-turn instructions. The MCP + * server is registered for the session's directory under the thread's own + * name and credential (the session rules allow only it), and removed when + * the thread unloads or the session closes. OpenCode 2 has no per-prompt + * system field, so the instructions are a session instructions entry, + * which applies from the next step; it is only rewritten when it changes. + */ + const prepareTurn = Effect.fnUntraced(function* ( + sessionId: string, + state: ThreadState, + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + ) { + const mcpSession = McpProviderSession.readMcpProviderSession(turnInput.threadId); + const directory = turnInput.runtimePolicy.cwd ?? serverConfig.cwd; + const name = t3McpServerName(turnInput.threadId); + // An external server may not reach T3's MCP endpoint, as with 1.x. + const wanted = + mcpSession === undefined || connection.external + ? undefined + : { name, directory, credential: mcpSession.authorizationHeader }; + if ( + state.mcp !== undefined && + (wanted === undefined || + state.mcp.directory !== wanted.directory || + state.mcp.credential !== wanted.credential) + ) { + yield* removeMcp(state.mcp); + state.mcp = undefined; + } + // T3's tools are an addition: a server that cannot add them still runs the turn. + if (wanted !== undefined && state.mcp === undefined) { + const added = yield* client.mcp + .add({ + server: name, + location: { directory }, + config: new Mcp.RemoteConfig({ + type: "remote", + url: mcpSession!.endpoint, + headers: { Authorization: wanted.credential }, + oauth: false, + }), + }) + .pipe( + Effect.timeout(INVENTORY_TIMEOUT), + Effect.as(true), + Effect.catchCause((cause) => + Effect.logWarning("Could not add T3 Code's MCP server to OpenCode.", cause).pipe( + Effect.as(false), + ), + ), + ); + if (added) state.mcp = wanted; + } + const instructions = [ + buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }), + t3OrchestrationSystemPrompt(state.mcp !== undefined), + ] + .filter((part) => part !== undefined && part.length > 0) + .join("\n\n"); + if (instructions !== state.instructions) { + yield* client.session.instructions.entry.put({ + sessionID: Session.ID.make(sessionId), + key: INSTRUCTIONS_KEY, + value: instructions, + }); + state.instructions = instructions; + } + }); + + const markBefore = (sessionId: string, before: string | null) => + Effect.sync(() => { + const turn = threads.get(sessionId)?.active; + if (turn !== undefined) turn.before = before; }); - return `${text}\n\n${instructions}`; - }; /** Installs a turn T3 started; every path after it ends the turn with a terminal. */ const beginTurn = ( @@ -2765,6 +3122,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr Effect.gen(function* () { const wake = state.wakes.shift(); if (wake === undefined) return yield* finishTurn(state, { status: "completed" }); + const turn = state.active; + if (turn !== undefined && wake.after !== undefined) turn.before = wake.after; yield* replay(wake); }), ); @@ -2851,6 +3210,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr Effect.orElseSucceed(() => []), ); if (commands.some((entry) => entry.name === command.name)) { + // `session.command` takes no id of T3's and its answer carries none, + // so the turn remembers where the history stood before it. + const newest = yield* client.message.list({ sessionID, order: "desc", limit: 1 }); + yield* markBefore(sessionId, newest.data[0]?.id ?? null); return yield* client.session.command({ sessionID, ...command }); } } @@ -2920,7 +3283,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const directory = threadInput.runtimePolicy.cwd ?? serverConfig.cwd; const policy = threadInput.runtimePolicy; // A new session runs OpenCode's default agent. - const permissions = yield* rulesFor({ directory, agent: "build", grants: [] }, policy); + const permissions = yield* rulesFor( + { directory, agent: "build", grants: [] }, + policy, + threadInput.threadId, + ); const created = yield* client.session.create({ location: Location.PublicRef.make({ directory: AbsolutePath.make(directory) }), model, @@ -3024,6 +3391,18 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr detail: `OpenCode session ${sessionId} already has an active turn`, }); } + // A lost stream is reconnecting: wait for it rather than prompt into + // a dead stream, and refuse the turn before any request if it gave up. + if (streamFailure !== undefined) { + yield* Deferred.await(reconnected).pipe(Effect.timeout(RECONNECT_WAIT), Effect.ignore); + } + if (streamFailure !== undefined) { + return yield* new ProviderAdapter.ProviderAdapterEventStreamError({ + driver, + providerSessionId: input.providerSessionId, + cause: streamFailure, + }); + } // OpenCode already ran this turn on its own; it prompts nothing. if (isContinuation(turnInput)) return yield* runWake(state, turnInput); // After a timed-out Stop the server says whether that run is gone. A @@ -3093,6 +3472,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // and a revert a failed rollback left staged would be committed. yield* cancelStrandedSteers(state); if (stagedReverts.has(sessionId)) yield* clearRevert(sessionId); + yield* prepareTurn(sessionId, state, turnInput); const turn = yield* begin; // An execution OpenCode is running on its own takes this prompt at // its next step, so this turn is that execution from here on. @@ -3260,7 +3640,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), ), unloadThread: ({ providerThread }) => - Effect.sync(() => { + Effect.gen(function* () { const nativeId = providerThread.nativeThreadRef?.nativeId; const state = nativeId == null ? undefined : threads.get(nativeId); if ( @@ -3277,6 +3657,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr childOwners.delete(child); threads.delete(child); } + if (state.mcp !== undefined) yield* removeMcp(state.mcp); }), respondToRuntimeRequest: (requestInput) => Effect.gen(function* () { @@ -3503,19 +3884,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // spawned server is not idle-stopped under a long tool call. openSession: (input) => Effect.gen(function* () { - const lent = yield* Deferred.make< - OpenCode2Server.OpenCode2Connection, - OpenCodeRuntimeError - >(); - yield* server - .withConnection((connection) => - Deferred.succeed(lent, connection).pipe(Effect.andThen(Effect.never)), - ) - .pipe( - Effect.catch((error) => Deferred.fail(lent, error)), - Effect.forkScoped, - ); - return yield* openSession(input, yield* Deferred.await(lent)); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer((exit) => Scope.close(scope, exit)); + const connection = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope)); + return yield* openSession(input, { connection, scope }); }).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index f16a38d35ae9..b18c35b7de8e 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -138,6 +138,13 @@ function materializeMessageIds(value: unknown, messageIds: ReadonlyMap<string, s */ export class OpenCodeReplayController { private cursor = 0; + /** Set once the event stream reached a successful `runtime_exit`: the server is gone. */ + exited = false; + + /** Whether every entry has been used, so a stopped server is not coming back. */ + get finished(): boolean { + return this.cursor >= this.transcript.entries.length; + } private readonly waiters = new Set<() => void>(); private failure: unknown = null; private readonly transcript: ProviderReplayTranscript; @@ -279,7 +286,10 @@ export class OpenCodeReplayController { } if (entry?.type === "runtime_exit") { this.advance(); - if (entry.status === "success") return; + if (entry.status === "success") { + this.exited = true; + return; + } const mismatch = new OpenCodeReplayMismatchError({ scenario: this.transcript.scenario, cursor: this.cursor - 1, diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 57a6b3943df3..893673808de6 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -10,6 +10,7 @@ import { MessageId, type ModelSelection, type OrchestrationV2Command, + type OrchestrationV2Run, ProjectId, type ProviderInteractionMode, ProviderInstanceId, @@ -40,6 +41,12 @@ import * as IdAllocator from "./IdAllocator.ts"; const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; /** Held until the scenario releases it, so the turn is still running meanwhile. */ const FIRST_TURN_END = "first-turn-end"; +/** Held until the scenario releases it, so a follow-up is running when its stream drops. */ +const BACKGROUND_HOLD = "background-hold"; +/** The parent session of the recorded background run. */ +const BACKGROUND_PARENT = "ses_f1485cda4ffeXuc6GjAU9vDiRb"; +const BACKGROUND_PROMPT = + "Use the subagent tool with background enabled to delegate to the general subagent with the prompt: 'Run the shell command `sleep 20` with the bash tool and then reply exactly CHILD_OK.' As soon as it is launched, reply exactly PARENT_OK and end your turn without waiting for it."; const instanceId = ProviderInstanceId.make("opencode"); const bigPickle: ModelSelection = { instanceId, model: "opencode/big-pickle" }; const mimo: ModelSelection = { instanceId, model: "opencode/mimo-v2.6-flash-free" }; @@ -62,7 +69,17 @@ const event = (type: string, data: Record<string, unknown>): ProviderReplayEntry }); const labelled = (entry: ProviderReplayEntry, label: string): ProviderReplayEntry => entry.type === "runtime_exit" ? entry : { ...entry, label }; -const T3_RULES = [{ action: "*", resource: "*", effect: "allow" }]; +/** + * T3's own rules after a mode's: every thread's T3 MCP server is denied, and + * then this thread's own is allowed again (last match wins). + */ +const mcpRules = (name: string) => [ + { action: "t3-code-*", resource: "*", effect: "deny" }, + { action: `t3-code-thread_${name}_*`, resource: "*", effect: "allow" }, +]; +const FULL_ACCESS = [{ action: "*", resource: "*", effect: "allow" }]; +/** Full access for the thread named `name`. */ +const t3Rules = (name: string) => [...FULL_ACCESS, ...mcpRules(name)]; /** Paths the build and plan agents allow for themselves, as 2.0.18 lists them. */ const BUILD_PATHS = [ { @@ -110,26 +127,29 @@ const noOpenRequests: ReadonlyArray<ProviderReplayEntry> = [ out("session.form.list", { sessionID: SESSION }), reply("session.form.list", { data: [] }), ]; -const SUPERVISED_RULES = [ +const supervisedRules = (name: string) => [ { action: "shell", resource: "*", effect: "ask" }, { action: "edit", resource: "*", effect: "ask" }, { action: "external_directory", resource: "*", effect: "ask" }, ...BUILD_PATHS, + ...mcpRules(name), ]; -const AUTO_EDIT_RULES = [ +const autoEditRules = (name: string) => [ { action: "shell", resource: "*", effect: "ask" }, { action: "edit", resource: "*", effect: "allow" }, { action: "external_directory", resource: "*", effect: "ask" }, ...BUILD_PATHS, + ...mcpRules(name), ]; /** Plan mode on Full access: edits are denied except the plan agent's own plan files. */ -const PLAN_RULES = [ +const planRules = (name: string) => [ { action: "*", resource: "*", effect: "allow" }, { action: "edit", resource: "*", effect: "deny" }, ...PLAN_PATHS, + ...mcpRules(name), ]; -const sessionInfo = (directory: string, permissions: ReadonlyArray<unknown> = T3_RULES) => ({ +const sessionInfo = (directory: string, permissions: ReadonlyArray<unknown>) => ({ data: { id: SESSION, projectID: "global", @@ -141,6 +161,11 @@ const sessionInfo = (directory: string, permissions: ReadonlyArray<unknown> = T3 permissions, }, }); +/** T3's instructions entry, written before a thread's first prompt and whenever it changes. */ +const instructionsWritten: ReadonlyArray<ProviderReplayEntry> = [ + out("session.instructions.entry.put", { sessionID: SESSION, key: "t3-code", value: "<any>" }), + reply("session.instructions.entry.put", null), +]; /** One prompt the server accepts and answers with `text`. */ const answeredPrompt = (text: string): ReadonlyArray<ProviderReplayEntry> => [ out("session.prompt", { sessionID: SESSION, text: "<any>" }), @@ -182,7 +207,10 @@ const catalogModel = (id: string, name: string) => ({ }); const createdSession = ( directory: string, - permissions: ReadonlyArray<unknown> = T3_RULES, + name: string, + permissions: ReadonlyArray<unknown> = t3Rules(name), + // Only a mode that narrows Full access reads the agents' own path rules. + narrows = false, ): ReadonlyArray<ProviderReplayEntry> => [ out("event.subscribe"), out("model.list", "<any>"), @@ -193,10 +221,7 @@ const createdSession = ( catalogModel("mimo-v2.6-flash-free", "MiMo V2.6 Flash Free"), ], }), - // Only a mode that narrows Full access reads the agents' own path rules. - ...(permissions === T3_RULES - ? [] - : [out("agent.list", "<any>"), reply("agent.list", agentList(directory))]), + ...(narrows ? [out("agent.list", "<any>"), reply("agent.list", agentList(directory))] : []), out("session.create", { location: { directory }, model: "<any>", permissions }), reply("session.create", sessionInfo(directory, permissions)), ]; @@ -293,16 +318,19 @@ describe("OpenCode 2 through the orchestrator", () => { name, threadId: thread.threadId, entries: [ - ...createdSession(cwd), + ...createdSession(cwd, name), + ...instructionsWritten, ...answeredPrompt("FIRST"), // The next turn resumes the session at its new selection. out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(cwd)), + reply("session.get", sessionInfo(cwd, t3Rules(name))), out("session.switchModel", { sessionID: SESSION, model: { providerID: "opencode", id: "mimo-v2.6-flash-free" }, }), reply("session.switchModel", null), + // The instructions name the model, so they are written again. + ...instructionsWritten, ...answeredPrompt("SECOND"), ], commands: [ @@ -344,14 +372,17 @@ describe("OpenCode 2 through the orchestrator", () => { name, threadId: thread.threadId, entries: [ - ...createdSession(before), + ...createdSession(before, name), + ...instructionsWritten, ...answeredPrompt("FIRST"), out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(before)), + reply("session.get", sessionInfo(before, t3Rules(name))), // The worktree change detached the thread, so its session is loaded afresh. ...noOpenRequests, out("session.move", { sessionID: SESSION, directory: after }), reply("session.move", null), + // The moved thread reopens its session, which writes the entry again. + ...instructionsWritten, ...answeredPrompt("SECOND"), ], commands: [ @@ -384,7 +415,8 @@ describe("OpenCode 2 through the orchestrator", () => { name, threadId: thread.threadId, entries: [ - ...createdSession(before), + ...createdSession(before, name), + ...instructionsWritten, ...answeredPrompt("FIRST"), // Reopened after a worktree change, the session reports the rules an // older build gave it, which denied subagents; they are replaced @@ -398,10 +430,11 @@ describe("OpenCode 2 through the orchestrator", () => { ]), ), ...noOpenRequests, - out("session.update", { sessionID: SESSION, permissions: T3_RULES }), + out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }), reply("session.update", null), out("session.move", { sessionID: SESSION, directory: after }), reply("session.move", null), + ...instructionsWritten, ...answeredPrompt("SECOND"), ], commands: [ @@ -437,7 +470,11 @@ describe("OpenCode 2 through the orchestrator", () => { const projection = yield* runScenario({ name, threadId: thread.threadId, - entries: [...createdSession(cwd, SUPERVISED_RULES), ...answeredPrompt("FIRST")], + entries: [ + ...createdSession(cwd, name, supervisedRules(name), true), + ...instructionsWritten, + ...answeredPrompt("FIRST"), + ], commands: [thread.create, thread.message("first")], }); assert.deepEqual( @@ -463,20 +500,21 @@ describe("OpenCode 2 through the orchestrator", () => { name, threadId: thread.threadId, entries: [ - ...createdSession(cwd), + ...createdSession(cwd, name), + ...instructionsWritten, ...answeredPrompt("FIRST"), // A mode change detaches nothing: the same session is resumed with the new rules. out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(cwd)), + reply("session.get", sessionInfo(cwd, t3Rules(name))), out("agent.list", "<any>"), reply("agent.list", agentList(cwd)), - out("session.update", { sessionID: SESSION, permissions: AUTO_EDIT_RULES }), + out("session.update", { sessionID: SESSION, permissions: autoEditRules(name) }), reply("session.update", null), ...answeredPrompt("SECOND"), // Back to Full access: the narrowing rules go. out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(cwd, AUTO_EDIT_RULES)), - out("session.update", { sessionID: SESSION, permissions: T3_RULES }), + reply("session.get", sessionInfo(cwd, autoEditRules(name))), + out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }), reply("session.update", null), ...answeredPrompt("THIRD"), ], @@ -506,14 +544,15 @@ describe("OpenCode 2 through the orchestrator", () => { name, threadId: thread.threadId, entries: [ - ...createdSession(cwd, PLAN_RULES), + ...createdSession(cwd, name, planRules(name), true), // Plan mode is also OpenCode's plan agent, switched before the prompt. out("session.switchAgent", { sessionID: SESSION, agent: "plan" }), reply("session.switchAgent", null), + ...instructionsWritten, ...answeredPrompt("PLANNED"), out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(cwd, PLAN_RULES)), - out("session.update", { sessionID: SESSION, permissions: T3_RULES }), + reply("session.get", sessionInfo(cwd, planRules(name))), + out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }), reply("session.update", null), out("session.switchAgent", { sessionID: SESSION, agent: "build" }), reply("session.switchAgent", null), @@ -631,7 +670,8 @@ describe("OpenCode 2 through the orchestrator", () => { version: "2.0.18", scenario: name, entries: [ - ...createdSession(cwd), + ...createdSession(cwd, name), + ...instructionsWritten, out("session.prompt", { sessionID: SESSION, id: "<any>", text: "<any>" }), reply("session.prompt", { data: { @@ -688,6 +728,244 @@ describe("OpenCode 2 through the orchestrator", () => { }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), ); + /** + * The recorded background run (`opencode2_background`): the parent's turn + * ends while its subagent runs, then the subagent's report wakes the parent + * and T3 opens a continuation run for that follow-up. `reconnect` replaces + * the recording from `cut` on with a stream drop, a restarted stream and + * what the server answers then. + */ + const backgroundReconnect = Effect.fn("backgroundReconnect")(function* (input: { + readonly name: string; + readonly cut: string; + readonly hold?: string; + readonly reconnect: (parent: string) => ReadonlyArray<ProviderReplayEntry>; + readonly steps: ( + thread: ReturnType<typeof threadCommands>, + run: (ordinal: number) => OrchestrationV2Run["id"], + ) => ReadonlyArray<OrchestratorV2ScenarioStep>; + readonly commands: ( + thread: ReturnType<typeof threadCommands>, + ) => ReadonlyArray<OrchestrationV2Command>; + }) { + const cwd = yield* checkpointWorkspace(input.name); + const recorded = yield* readProviderReplayTranscript( + new URL( + "./testkit/fixtures/opencode2_background/opencode_transcript.ndjson", + import.meta.url, + ), + ); + const cut = recorded.entries.findIndex( + (entry) => entry.type !== "runtime_exit" && entry.label === input.cut, + ); + assert.isAtLeast(cut, 0); + const kept = recorded.entries + .slice(0, cut) + .map((entry) => + entry.type !== "runtime_exit" && entry.label === input.hold + ? labelled(entry, BACKGROUND_HOLD) + : entry, + ); + const transcript = yield* OpenCode2OrchestratorReplayHarness.decodeTranscript({ + ...recorded, + scenario: input.name, + entries: [ + ...kept, + { type: "runtime_exit", status: "success" }, + ...input.reconnect(BACKGROUND_PARENT), + ], + }); + const thread = threadCommands({ name: input.name, worktreePath: cwd }); + const ids = yield* IdAllocator.IdAllocatorV2; + const run = (ordinal: number) => ids.derive.run({ threadId: thread.threadId, ordinal }); + const result = yield* runOrchestratorV2ProviderReplayScenario( + { + name: input.name, + transcript, + commands: input.commands(thread), + steps: input.steps(thread, run), + }, + OpenCode2OrchestratorReplayHarness, + // Opens the continuation run a follow-up asks for, as the server does. + { runContinuationWorker: true }, + ).pipe(provideDeterministicTestRuntime); + const projection = result.projections.get(thread.threadId); + assert.isDefined(projection); + return { result, projection }; + }); + + it.effect("backfills a follow-up whose stream dropped mid-run and ends it completed", () => + Effect.gen(function* () { + const { result, projection } = yield* backgroundReconnect({ + name: "opencode2-follow-up-reconnect", + // The follow-up streams its reply; then the stream drops before the + // execution's end. + cut: "session.step.streamed.5", + hold: "session.text.ended.3", + reconnect: (parent) => [ + out("event.subscribe"), + out("session.active"), + reply("session.active", { data: {} }), + // The follow-up's history since the report it answers, newest first. + out("message.list", { sessionID: parent, order: "desc", limit: "50" }), + reply("message.list", { + data: [ + { + id: "msg_0eb7a99c0001idleFollowUp00", + time: { created: 5 }, + type: "idle", + outcome: "succeeded", + }, + { + id: "msg_0eb7a96bb00170K1f1HJUeBNwF", + time: { created: 4 }, + type: "assistant", + agent: "build", + model: { id: "big-pickle", providerID: "opencode", variant: "default" }, + content: [ + { + type: "text", + text: "The background subagent finished and returned `CHILD_OK`.", + }, + ], + finish: "stop", + }, + { + id: "msg_0eb7a3aee0015KmTB8XkMDW8iy", + time: { created: 3 }, + type: "synthetic", + text: "<subagent>CHILD_OK</subagent>", + }, + { + id: "msg_0eb7a3e3c001idleParentTurn0", + time: { created: 2 }, + type: "idle", + outcome: "succeeded", + }, + ], + cursor: {}, + }), + ], + commands: (thread) => [ + thread.create, + thread.message("start", bigPickle, BACKGROUND_PROMPT), + ], + steps: (thread, run) => [ + { type: "dispatch", command: thread.create }, + { type: "advance_clock", duration: "1 millis" }, + // A continuation run starts while the thread is busy, so this does not wait. + { + type: "dispatch", + command: thread.message("start", bigPickle, BACKGROUND_PROMPT), + await: false, + key: "start", + }, + { type: "advance_clock", duration: "1 millis" }, + // The follow-up's continuation turn has taken its execution when the + // stream drops: the held reply end is the next event. + { type: "await_run_steerable", threadId: thread.threadId, runId: run(2) }, + { type: "release_replay_gate", label: BACKGROUND_HOLD }, + { + type: "await_run_status", + threadId: thread.threadId, + runId: run(2), + status: "completed", + }, + { type: "await", key: "start" }, + { type: "await_thread_idle", threadId: thread.threadId }, + ], + }); + assert.deepEqual( + projection.runs.map((candidate) => candidate.status), + ["completed", "completed"], + ); + const followUp = projection.runs[1]; + assert.deepEqual( + projection.turnItems.flatMap((item) => + item.runId === followUp?.id && item.type === "assistant_message" ? [item.text] : [], + ), + ["The background subagent finished and returned `CHILD_OK`."], + ); + const shell = result.shellSnapshot.threads.find((row) => row.id === projection.thread.id); + assert.deepEqual(shell?.pendingBackgroundTasks ?? [], []); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), + ); + + it.effect( + "settles a background subagent whose end was lost with the stream, and takes the next turn", + () => + Effect.gen(function* () { + const { result, projection } = yield* backgroundReconnect({ + name: "opencode2-background-lost", + // The subagent runs; the stream drops before its end, its report and + // the follow-up it started, none of which is read back. + cut: "session.step.started.3", + reconnect: (parent) => [ + out("event.subscribe"), + // Nothing runs any more: the subagent and the follow-up both ended. + out("session.active"), + reply("session.active", { data: {} }), + out("session.prompt", { sessionID: parent, id: "<any>", text: "<any>" }), + reply("session.prompt", { + data: { + id: "msg_next", + sessionID: parent, + time: { created: 9 }, + type: "user", + payload: { text: "<prompt>" }, + delivery: "steer", + }, + }), + event("session.execution.started", { sessionID: parent }), + event("session.text.ended", { + sessionID: parent, + assistantMessageID: "msg_next_reply", + ordinal: 0, + text: "NEXT", + }), + event("session.execution.succeeded", { sessionID: parent }), + ], + commands: (thread) => [ + thread.create, + thread.message("start", bigPickle, BACKGROUND_PROMPT), + thread.message("next"), + ], + steps: (thread, run) => [ + { type: "dispatch", command: thread.create }, + { type: "advance_clock", duration: "1 millis" }, + { type: "dispatch", command: thread.message("start", bigPickle, BACKGROUND_PROMPT) }, + { type: "advance_clock", duration: "1 millis" }, + { type: "await_thread_idle", threadId: thread.threadId }, + { type: "dispatch", command: thread.message("next") }, + { type: "advance_clock", duration: "1 millis" }, + { + type: "await_run_status", + threadId: thread.threadId, + runId: run(2), + status: "completed", + }, + { type: "await_thread_idle", threadId: thread.threadId }, + ], + }); + // The parent's run ended; the lost subagent ended as interrupted and + // said why; no follow-up is waited on; the next turn ran normally. + assert.deepEqual( + projection.runs.map((candidate) => candidate.status), + ["completed", "completed"], + ); + assert.lengthOf(projection.subagents, 1); + assert.deepInclude(projection.subagents[0], { status: "interrupted" }); + assert.include(projection.subagents[0]?.result ?? "", "lost its connection to OpenCode"); + assert.isFalse( + projection.turnItems.some( + (item) => item.status === "running" || item.status === "waiting", + ), + ); + const shell = result.shellSnapshot.threads.find((row) => row.id === projection.thread.id); + assert.deepEqual(shell?.pendingBackgroundTasks ?? [], []); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), + ); + it.effect( "runs plan mode as OpenCode's plan agent and switches back before the next prompt", () => diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index e66a7ce645ee..142be0cac521 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -12,7 +12,9 @@ * The server runs with isolated HOME and XDG directories on the free * `opencode/big-pickle` model; `OPENCODE2_MODEL` picks another (its provider's * key comes from the test's environment, which the spawned server inherits). - * A second run covers plan mode, a workspace command and skill, and `/compact`. + * A second run covers plan mode, a workspace command and skill, and `/compact`; + * a third a generated title, T3's MCP server (`OPENCODE2_MCP_URL` names a + * stand-in one) and a turn cut off by a killed server. */ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -36,7 +38,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { FetchHttpClient } from "effect/unstable/http"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { describe } from "vite-plus/test"; @@ -52,6 +54,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import { ProviderInstanceRegistryHydrationLive } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts"; +import * as OpenCode2Client from "../provider/opencode2/OpenCode2Client.ts"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -66,7 +69,7 @@ import * as IdAllocator from "./IdAllocator.ts"; import * as ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; import * as ProviderContinuationService from "./ProviderContinuationService.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; -import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; +import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; const binaryPath = process.env.OPENCODE2_BIN; const ROOT = process.env.OPENCODE2_LIVE_ROOT ?? ""; @@ -78,6 +81,47 @@ const MODEL: ModelSelection = { // The free model the thread switches to mid-conversation. const SWITCHED_MODEL = "opencode/mimo-v2.6-flash-free"; +/** The OpenCode servers the driver spawned, newest last, so a test can kill one by its own PID. */ +const spawnedPids: Array<number> = []; +const spawnedServers = Layer.succeed( + OpenCodeServerLedger.OpenCodeServerLedger, + OpenCodeServerLedger.OpenCodeServerLedger.of({ + track: ({ pid }) => + Effect.sync(() => { + spawnedPids.push(pid); + return Effect.void; + }), + }), +); + +/** + * Credentials for T3's MCP server. `OPENCODE2_MCP_URL` points them at a stand-in + * MCP server the run can see called; without it they point nowhere, as in replay. + */ +const MCP_URL = process.env.OPENCODE2_MCP_URL ?? "http://127.0.0.1/mcp"; +const mcpRegistryLayer = Layer.succeed( + McpSessionRegistry.McpSessionRegistry, + McpSessionRegistry.McpSessionRegistry.of({ + issue: ({ threadId, providerInstanceId }) => + Effect.succeed({ + config: { + environmentId: EnvironmentId.make("environment:opencode2-live"), + threadId, + providerSessionId: `mcp-live:${threadId}`, + providerInstanceId, + endpoint: MCP_URL, + authorizationHeader: `Bearer mcp-live:${threadId}`, + browserToolsAvailable: false, + }, + }), + resolve: () => Effect.succeed(undefined), + touch: () => Effect.void, + revokeProviderSession: () => Effect.void, + revokeThread: () => Effect.void, + revokeAll: Effect.void, + }), +); + const PlatformTestLayer = Layer.merge( NodeServices.layer, Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ @@ -103,7 +147,17 @@ const serverSettingsLayer = ServerSettings.layerTest({ { name: "XDG_STATE_HOME", value: `${ROOT}/state` }, { name: "XDG_CACHE_HOME", value: `${ROOT}/cache` }, ], - config: { enabled: true, binaryPath }, + // `OPENCODE2_SERVER_URL` connects to an external server instead of spawning one. + config: { + enabled: true, + binaryPath, + ...(process.env.OPENCODE2_SERVER_URL === undefined + ? {} + : { + serverUrl: process.env.OPENCODE2_SERVER_URL, + serverPassword: process.env.OPENCODE2_SERVER_PASSWORD ?? "", + }), + }, }, }, }); @@ -123,7 +177,7 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe NodeServices.layer, FetchHttpClient.layer, OpenCodeRuntime.OpenCodeRuntimeLive.pipe( - Layer.provide(OpenCodeServerLedger.layerTest), + Layer.provide(spawnedServers), Layer.provide(PlatformTestLayer), ), Layer.succeed( @@ -150,7 +204,7 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe ); const orchestrationLayer = OrchestrationV2LayerLive.pipe( Layer.provide(worktreeRepairDependenciesTestLayer), - Layer.provide(McpSessionRegistryTestkit.layer), + Layer.provide(mcpRegistryLayer), Layer.provide(SqlitePersistenceMemory), Layer.provide(CheckpointStore.layer.pipe(Layer.provide(vcsDriverRegistryLayer))), Layer.provide(serverConfigLayer), @@ -960,4 +1014,151 @@ describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchest }).pipe(Effect.provide(Layer.merge(liveLayer, NodeServices.layer)), Effect.scoped), 360_000, ); + + it.live( + "generates a title, calls T3's MCP server, and reconciles a turn cut off by a killed server", + () => + Effect.gen(function* () { + const work = `${ROOT}/work`; + yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); + const orchestrator = yield* Orchestrator.OrchestratorV2; + const instance = + yield* (yield* ProviderInstanceRegistry.ProviderInstanceRegistry).getInstance(INSTANCE); + assert.isDefined(instance); + yield* instance!.snapshot.refresh; + + // A thread title, generated in a temporary session on the 2.x server. + const title = yield* instance!.textGeneration.generateThreadTitle({ + cwd: work, + message: "fix the login redirect loop after oauth", + modelSelection: MODEL, + }); + assert.isAbove(title.title.length, 0); + + const threadId = ThreadId.make("thread:opencode2-live-restart"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:opencode2-live-restart:create"), + threadId, + projectId: ProjectId.make("project:opencode2-live-restart"), + title: "OpenCode 2 live restart", + modelSelection: MODEL, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: work, + }); + const runs = (count: number) => (projection: OrchestrationV2ThreadProjection) => + projection.runs.length === count && settled(projection); + + // T3's MCP server for this thread: the stand-in's one tool answers a marker. + if (process.env.OPENCODE2_MCP_URL !== undefined) { + yield* send( + threadId, + "restart-mcp", + "Call the echo_marker tool from the T3 Code MCP server with word 'kiwi', then reply with its exact output and nothing else.", + ); + const called = yield* waitFor(threadId, runs(1)); + assert.equal(called.runs[0]?.status, "completed"); + const reply = called.turnItems.findLast((item) => item.type === "assistant_message"); + assert.include(reply?.type === "assistant_message" ? reply.text : "", "MARKER-KIWI-7Q9"); + } + const before = (yield* orchestrator.getThreadProjection(threadId)).runs.length; + + // The spawned server dies mid-command; T3 restarts it and settles the turn. + yield* send( + threadId, + "restart-killed", + "Run the shell command `sleep 60 && echo LATE` with the shell tool in the foreground and wait for it, then reply DONE.", + ); + yield* waitFor(threadId, (projection) => + projection.turnItems.some( + (item) => + item.type === "command_execution" && + item.status === "running" && + item.input.includes("sleep 60"), + ), + ); + const pid = spawnedPids.at(-1); + assert.isDefined(pid); + process.kill(pid!, "SIGKILL"); + const reconciled = yield* waitFor(threadId, runs(before + 1)); + assert.equal(reconciled.runs.at(-1)?.status, "interrupted"); + + yield* send( + threadId, + "restart-next", + "What did I last ask you to run? Answer in one short sentence.", + ); + const next = yield* waitFor(threadId, runs(before + 2)); + assert.equal(next.runs.at(-1)?.status, "completed"); + const answer = next.turnItems.findLast((item) => item.type === "assistant_message"); + assert.include(answer?.type === "assistant_message" ? answer.text : "", "sleep 60"); + assert.lengthOf(next.providerThreads, 1); + }).pipe(Effect.provide(Layer.merge(liveLayer, NodeServices.layer)), Effect.scoped), + 360_000, + ); + + // Needs an external server behind a proxy that cuts its event streams on + // `GET <proxy>/__drop`, so only the stream drops while the server stays up. + it.live.runIf(process.env.OPENCODE2_DROP_URL !== undefined)( + "picks a turn back up after an external server's event stream drops mid-turn", + () => + Effect.gen(function* () { + const work = `${ROOT}/work`; + yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("thread:opencode2-live-drop"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:opencode2-live-drop:create"), + threadId, + projectId: ProjectId.make("project:opencode2-live-drop"), + title: "OpenCode 2 live drop", + modelSelection: MODEL, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: work, + }); + yield* send( + threadId, + "drop-running", + "Run the shell command `sleep 8 && echo AFTER_DROP` with the shell tool in the foreground and wait for it, then reply with its exact output.", + ); + yield* waitFor(threadId, (projection) => + projection.turnItems.some( + (item) => item.type === "command_execution" && item.status === "running", + ), + ); + yield* HttpClient.get(process.env.OPENCODE2_DROP_URL!).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.orDie, + ); + // The turn keeps running on the new stream and ends with its reply. + const done = yield* waitFor(threadId, settled); + assert.equal(done.runs[0]?.status, "completed"); + const shell = done.turnItems.find((item) => item.type === "command_execution"); + assert.equal(shell?.status, "completed"); + const reply = done.turnItems.findLast((item) => item.type === "assistant_message"); + assert.include(reply?.type === "assistant_message" ? reply.text : "", "AFTER_DROP"); + // The thread's T3 MCP server is registered on the external server for now. + const opencode = yield* OpenCode2Client.make.pipe(Effect.provide(FetchHttpClient.layer)); + const api = yield* opencode.connect({ + baseUrl: process.env.OPENCODE2_SERVER_URL!, + password: process.env.OPENCODE2_SERVER_PASSWORD ?? "", + }); + const servers = yield* api.client.mcp.list({ location: { directory: work } }); + assert.deepEqual( + servers.data.map((server) => server.name), + [], + "an external server gets no T3 MCP server, as with 1.x", + ); + }).pipe(Effect.provide(Layer.merge(liveLayer, NodeServices.layer)), Effect.scoped), + 360_000, + ); }); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index 148b90714b9a..28f97339e67b 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -73,6 +73,8 @@ import { openCode2BackgroundInput } from "./opencode2_background/input.ts"; import { assertOpenCode2BackgroundOutput } from "./opencode2_background/output.ts"; import { openCode2SubagentInput } from "./opencode2_subagent/input.ts"; import { assertOpenCode2SubagentOutput } from "./opencode2_subagent/output.ts"; +import { openCode2ResumeAfterRestartInput } from "./opencode2_resume_after_restart/input.ts"; +import { assertOpenCode2ResumeAfterRestartOutput } from "./opencode2_resume_after_restart/output.ts"; import { openCode2SimpleInput } from "./opencode2_simple/input.ts"; import { openCode2SkillInput } from "./opencode2_skill/input.ts"; import { assertOpenCode2SkillOutput } from "./opencode2_skill/output.ts"; @@ -949,6 +951,24 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray<OrchestratorReplayFixtu }, ], }, + { + name: "opencode2_resume_after_restart", + buildInput: openCode2ResumeAfterRestartInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode"), + transcriptFile: new URL( + "./opencode2_resume_after_restart/opencode_transcript.ndjson", + import.meta.url, + ), + modelSelection: { + instanceId: OPENCODE2_MODEL_SELECTION.instanceId, + model: "opencode/big-pickle", + }, + assertOutput: assertOpenCode2ResumeAfterRestartOutput, + }, + ], + }, // Recorded live against 2.0.18 on OpenRouter with the workspace's own command and skill. ...( [ diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background/opencode_transcript.ndjson index 5c4abc69c10d..104b2c2aadf6 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_background", "metadata": {"source": "opencode2-spike/recordings/background.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_background", "metadata": {"source": "opencode2-spike/recordings/background.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} @@ -6,6 +6,8 @@ {"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": "<any>"}} {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657049201, "updated": 1790657049201}, "title": "spike background", "location": {"directory": "<work>"}}}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3271001Z06XG1eDh56UaK", "created": 1790657049201, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "slug": "cosmic-harbor", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "spike background", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "text": "<any>"}}} {"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7a327f001vnY9Kg4imbAi0P", "sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "time": {"created": 1790657049216}, "type": "user", "payload": {"text": "Use the subagent tool with background enabled to delegate to the general subagent with the prompt: 'Run the shell command `sleep 20` with the bash tool and then reply exactly CHILD_OK.' As soon as it is launched, reply exactly PARENT_OK and end your turn without waiting for it."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7a3280001qlssYW5zgVI8Tw", "created": 1790657049216, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb7a327f001vnY9Kg4imbAi0P", "sessionID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "item": {"type": "user", "payload": {"text": "Use the subagent tool with background enabled to delegate to the general subagent with the prompt: 'Run the shell command `sleep 20` with the bash tool and then reply exactly CHILD_OK.' As soon as it is launched, reply exactly PARENT_OK and end your turn without waiting for it."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f1485cda4ffeXuc6GjAU9vDiRb", "seq": 1, "version": 1}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/opencode_transcript.ndjson index a50529b58e8a..36c35d5799ad 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_command/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_command", "metadata": {"source": "oc2-8 live recording command_t3.ndjson (2.0.18, openrouter/deepseek/deepseek-v4-flash)", "capturedAt": "2026-09-29", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary: `/hello WORLD` names a workspace command (.opencode/command/hello.md), so T3 reads the directory's commands and runs it with session.command; OpenCode expands the template into the user message."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_command", "metadata": {"source": "oc2-8 live recording command_t3.ndjson (2.0.18, openrouter/deepseek/deepseek-v4-flash)", "capturedAt": "2026-09-29", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary: `/hello WORLD` names a workspace command (.opencode/command/hello.md), so T3 reads the directory's commands and runs it with session.command; OpenCode expands the template into the user message. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn. Before the command, T3 reads the session's newest history item (none on a new session, as 2.0.18 answers) so a reconnect can backfill a command, whose answer carries no item id."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "deepseek/deepseek-v4-flash", "modelID": "deepseek/deepseek-v4-flash", "providerID": "openrouter", "family": "deepseek-flash", "name": "DeepSeek V4 Flash", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openrouter", "settings": {"baseURL": "https://openrouter.ai/api/v1"}, "headers": {"HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [{"id": "none", "settings": {"reasoning": {"enabled": false}}}, {"id": "high", "settings": {"reasoning": {"effort": "high"}}}, {"id": "xhigh", "settings": {"reasoning": {"effort": "xhigh"}}}], "time": {"released": 1776988800000}, "cost": [{"input": 0.14, "output": 0.28, "cache": {"read": 0.028, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "output": 131072}}]}}} @@ -6,9 +6,13 @@ {"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": "<any>"}} {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "projectID": "global", "model": {"id": "deepseek/deepseek-v4-flash", "providerID": "openrouter", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790747096015, "updated": 1790747096015}, "title": "oc2-8 command_t3", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}], "location": {"directory": "<work>"}}}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d833cf001vCmbwtG6y6Wg1U", "created": 1790747096015, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "slug": "shiny-squid", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "oc2-8 command_t3", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}], "model": {"id": "deepseek/deepseek-v4-flash", "providerID": "openrouter"}}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "command.list", "frame": {"type": "command.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "command.list.response", "frame": {"type": "sdk.response", "operation": "command.list", "data": {"location": {"directory": "<work>"}, "data": [{"name": "init", "description": "guided AGENTS.md setup"}, {"name": "review", "description": "review changes [commit|branch|pr], defaults to uncommitted"}, {"name": "hello", "description": "Say hello to the workspace"}, {"name": "hello2", "description": "plural dir command"}]}}} {"type": "emit_inbound", "label": "project.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d833e100154h183ZQ8TFIz9", "created": 1790747096033, "type": "project.updated", "data": {"id": "global", "canonical": "<work>", "vcs": "git", "time": {"created": 1790744321645, "updated": 1790744321645, "active": 1790747096032}, "sandboxes": []}}}} +{"type": "expect_outbound", "label": "message.list", "frame": {"type": "message.list", "input": "<any>"}} +{"type": "emit_inbound", "label": "message.list.response", "frame": {"type": "sdk.response", "operation": "message.list", "data": {"data": [], "cursor": {"previous": null, "next": null}}}} {"type": "expect_outbound", "label": "session.command", "frame": {"type": "session.command", "input": {"sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "name": "hello", "text": "WORLD"}}} {"type": "emit_inbound", "label": "session.command.response", "frame": {"type": "sdk.response", "operation": "session.command", "data": null}} {"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d833f7001CR8gqnGibZ5mIH", "created": 1790747096055, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0f0d833f4001DhEyXmiqfz0kcF", "sessionID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "item": {"type": "user", "payload": {"text": "Reply with exactly: HELLO WORLD"}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f0f27cc49ffe6wl0jdiu4kO4FC", "seq": 1, "version": 1}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/opencode_transcript.ndjson index 357816f73f1c..3469e11199a2 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_compaction", "metadata": {"source": "opencode2-spike/recordings/compaction.ndjson", "capturedAt": "2026-09-28", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: a turn, /compact through session.compact, then a turn that recalls the compacted fact."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_compaction", "metadata": {"source": "opencode2-spike/recordings/compaction.ndjson", "capturedAt": "2026-09-28", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: a turn, /compact through session.compact, then a turn that recalls the compacted fact. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} @@ -6,6 +6,8 @@ {"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": "<any>"}} {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f148492e3ffeQ91rU0GmbvwzYL", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657129772, "updated": 1790657129772}, "title": "spike compaction", "location": {"directory": "<work>"}}}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b6d2c0017hjEa3lkp3ZkkO", "created": 1790657129772, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "slug": "neon-wizard", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "spike compaction", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "text": "<any>"}}} {"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7b6d3b001py2GMD5J7EycIR", "sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "time": {"created": 1790657129788}, "type": "user", "payload": {"text": "Remember the codeword PAPAYA. Reply OK."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b6d3c001Z9szA45fhovmFz", "created": 1790657129788, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb7b6d3b001py2GMD5J7EycIR", "sessionID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "item": {"type": "user", "payload": {"text": "Remember the codeword PAPAYA. Reply OK."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f148492e3ffeQ91rU0GmbvwzYL", "seq": 1, "version": 1}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson index c0bc49449905..a327f5ffc9ed 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_fork", "metadata": {"source": "opencode2-spike/recordings/fork.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f1484ea8affe5aTo8CcgbGIszj", "forkedNativeSessionId": "ses_f1484db83ffeLGtrRCFimo1H0e", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: two turns, then a fork before the second turn's user message, then a turn on the fork that only sees the first."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_fork", "metadata": {"source": "opencode2-spike/recordings/fork.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f1484ea8affe5aTo8CcgbGIszj", "forkedNativeSessionId": "ses_f1484db83ffeLGtrRCFimo1H0e", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: two turns, then a fork before the second turn's user message, then a turn on the fork that only sees the first. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn. The fork is a new thread to T3, so its first turn writes the entry on the fork too."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} @@ -6,6 +6,8 @@ {"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": "<any>"}} {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f1484ea8affe5aTo8CcgbGIszj", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657107336, "updated": 1790657107336}, "title": "spike fork", "location": {"directory": "<work>"}}}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1588001REWGPFiBf7rtLq", "created": 1790657107336, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "slug": "playful-garden", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "spike fork", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "id": "msg_0eb7b1597001e14VBV3nmQYdFS", "text": "<any>"}}} {"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7b1597001e14VBV3nmQYdFS", "sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "time": {"created": 1790657107353}, "type": "user", "payload": {"text": "Reply exactly ONE."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b1599001jGsE2bT343I3x4", "created": 1790657107353, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb7b1597001e14VBV3nmQYdFS", "sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "item": {"type": "user", "payload": {"text": "Reply exactly ONE."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f1484ea8affe5aTo8CcgbGIszj", "seq": 1, "version": 1}}}} @@ -45,9 +47,11 @@ {"type": "emit_inbound", "label": "message.list.response.2", "frame": {"type": "sdk.response", "operation": "message.list", "data": {"data": [], "cursor": {}}}} {"type": "expect_outbound", "label": "session.fork", "frame": {"type": "session.fork", "input": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "before": "msg_0eb7b1f9f001g95Bduh3bi8syS"}}} {"type": "emit_inbound", "label": "session.fork.response", "frame": {"type": "sdk.response", "operation": "session.fork", "data": {"data": {"id": "ses_f1484db83ffeLGtrRCFimo1H0e", "fork": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "boundary": {"type": "before", "messageID": "msg_0eb7b1f9f001g95Bduh3bi8syS"}}, "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657111165, "updated": 1790657111165}, "title": "spike fork (fork #1)", "location": {"directory": "<work>"}, "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "subagent", "resource": "*", "effect": "deny"}]}}}} -{"type": "expect_outbound", "label": "session.update", "frame": {"type": "session.update", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}]}}} +{"type": "expect_outbound", "label": "session.update", "frame": {"type": "session.update", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "t3-code-*", "resource": "*", "effect": "deny"}, {"action": "t3-code-thread_opencode2-fork_target_*", "resource": "*", "effect": "allow"}]}}} {"type": "emit_inbound", "label": "session.update.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}} {"type": "emit_inbound", "label": "session.forked", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b247d001fXzFroPg5P0bps", "created": 1790657111165, "type": "session.forked", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "parentID": "ses_f1484ea8affe5aTo8CcgbGIszj", "boundary": {"type": "before", "messageID": "msg_0eb7b1f9f001g95Bduh3bi8syS"}, "instructions": {"core/environment": "db4a793a05fec32fdfa985a3432125a86d6cb42debc8b9ee8f6a99fbfcf2f93b", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}, "instructionEntries": []}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 0, "version": 2}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put.2", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response.2", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt.3", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "id": "msg_0eb7b2493001nZTYCniIjU5Y65", "text": "<any>"}}} {"type": "emit_inbound", "label": "session.prompt.response.3", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7b2493001nZTYCniIjU5Y65", "sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "time": {"created": 1790657111188}, "type": "user", "payload": {"text": "What did you reply last time? Answer in one word."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b2494001M14VU7KlwYgdaF", "created": 1790657111188, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb7b2493001nZTYCniIjU5Y65", "sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "item": {"type": "user", "payload": {"text": "What did you reply last time? Answer in one word."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 13, "version": 1}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/opencode_transcript.ndjson index e0365d7ca75a..b0f63d8ee928 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_inbox/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_inbox", "metadata": {"source": "opencode2-spike/recordings/inbox.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f14854c71ffeuPLxKSK70AHWvd", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: a steer lands in a running shell turn at its next step boundary. The recording also queued two prompts natively; T3 queues them itself, so those requests are dropped and the first queued message runs as its own turn, in the execution OpenCode ran it in, after the steered turn ends. The steer is sent while the shell call runs, as T3 can only steer a turn it sees running."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_inbox", "metadata": {"source": "opencode2-spike/recordings/inbox.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f14854c71ffeuPLxKSK70AHWvd", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: a steer lands in a running shell turn at its next step boundary. The recording also queued two prompts natively; T3 queues them itself, so those requests are dropped and the first queued message runs as its own turn, in the execution OpenCode ran it in, after the steered turn ends. The steer is sent while the shell call runs, as T3 can only steer a turn it sees running. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} @@ -6,6 +6,8 @@ {"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": "<any>"}} {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f14854c71ffeuPLxKSK70AHWvd", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657082272, "updated": 1790657082272}, "title": "spike inbox", "location": {"directory": "<work>"}}}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ab3a0001MfhwH3aV04StYO", "created": 1790657082272, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "slug": "lucky-mountain", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "spike inbox", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "id": "msg_0eb7ab3b9001sMV4nmgEXFKFfw", "text": "<any>"}}} {"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7ab3b9001sMV4nmgEXFKFfw", "sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "time": {"created": 1790657082299}, "type": "user", "payload": {"text": "Run the shell command `sleep 12 && echo A` with the bash tool, then reply DONE_A."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7ab3bb001AZRfHxE6SeDXCc", "created": 1790657082299, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb7ab3b9001sMV4nmgEXFKFfw", "sessionID": "ses_f14854c71ffeuPLxKSK70AHWvd", "item": {"type": "user", "payload": {"text": "Run the shell command `sleep 12 && echo A` with the bash tool, then reply DONE_A."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14854c71ffeuPLxKSK70AHWvd", "seq": 1, "version": 1}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_interrupt/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_interrupt/opencode_transcript.ndjson index 346c0b5e3606..ce4c048e1900 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_interrupt/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_interrupt/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_interrupt", "metadata": {"source": "opencode2-spike/recordings/interrupt.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_interrupt", "metadata": {"source": "opencode2-spike/recordings/interrupt.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} @@ -6,6 +6,8 @@ {"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": "<any>"}} {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790656609743, "updated": 1790656609743}, "title": "spike interrupt", "location": {"directory": "<work>"}}}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb737dcf001Bcj0k2QTAM0Kvj", "created": 1790656609743, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "slug": "gentle-cabin", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "spike interrupt", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "text": "<any>"}}} {"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb737de3001m3HJF1mWJrpc0l", "sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "time": {"created": 1790656609769}, "type": "user", "payload": {"text": "Run the shell command `sleep 60 && echo LATE` with the bash tool, then reply DONE."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb737de9001N9VhyJRu4KRiDv", "created": 1790656609769, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb737de3001m3HJF1mWJrpc0l", "sessionID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "item": {"type": "user", "payload": {"text": "Run the shell command `sleep 60 && echo LATE` with the bash tool, then reply DONE."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f148c8240ffegmJ5ZhqhyYOcSH", "seq": 1, "version": 1}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission/opencode_transcript.ndjson index 804e9a570a94..5883f74adbb5 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_permission", "metadata": {"source": "opencode2-spike/recordings/permission.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f1483bd0affez3sG5IbKgOai7e", "description": "A real OpenCode 2.0.18 Supervised session recorded at the HTTP and SSE boundary by the spike recorder: the first shell ask approved once, then the command and two retries declined. T3's rule reads and the decline notes are spliced in where it sends them."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_permission", "metadata": {"source": "opencode2-spike/recordings/permission.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f1483bd0affez3sG5IbKgOai7e", "description": "A real OpenCode 2.0.18 Supervised session recorded at the HTTP and SSE boundary by the spike recorder: the first shell ask approved once, then the command and two retries declined. T3's rule reads and the decline notes are spliced in where it sends them. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} @@ -8,6 +8,8 @@ {"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": "<any>"}} {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f1483bd0affez3sG5IbKgOai7e", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657184522, "updated": 1790657184522}, "title": "spike permission", "permissions": [{"action": "shell", "resource": "*", "effect": "ask"}, {"action": "edit", "resource": "*", "effect": "ask"}, {"action": "external_directory", "resource": "*", "effect": "ask"}, {"action": "external_directory", "resource": "<home>/data/opencode/shell/*/*", "effect": "allow"}, {"action": "external_directory", "resource": "<home>/data/opencode/tool-output/*", "effect": "allow"}, {"action": "external_directory", "resource": "/tmp/opencode/*", "effect": "allow"}, {"action": "external_directory", "resource": "<home>/config/opencode/*", "effect": "allow"}, {"action": "subagent", "resource": "*", "effect": "deny"}], "location": {"directory": "<work>"}}}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c43090019BtXQ5r0ZjDWSV", "created": 1790657184522, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "slug": "misty-orchid", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "spike permission", "permissions": [{"action": "shell", "resource": "*", "effect": "ask"}], "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "text": "<any>"}}} {"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7c4316001q0CD2uF5bwp7Mo", "sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "time": {"created": 1790657184536}, "type": "user", "payload": {"text": "Run the shell command `echo FIRST` with the bash tool. After it completes, run `echo SECOND` with the bash tool. Then reply with what happened."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7c4318001qcZWVfG736x3eX", "created": 1790657184536, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb7c4316001q0CD2uF5bwp7Mo", "sessionID": "ses_f1483bd0affez3sG5IbKgOai7e", "item": {"type": "user", "payload": {"text": "Run the shell command `echo FIRST` with the bash tool. After it completes, run `echo SECOND` with the bash tool. Then reply with what happened."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f1483bd0affez3sG5IbKgOai7e", "seq": 1, "version": 1}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/opencode_transcript.ndjson index 4b00932c5fc1..b36930e840a2 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_question", "metadata": {"source": "opencode2-spike/recordings/question.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f14865e15ffewHnFwxdBlN6Eqc", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: the question tool's form is answered through T3."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_question", "metadata": {"source": "opencode2-spike/recordings/question.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f14865e15ffewHnFwxdBlN6Eqc", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: the question tool's form is answered through T3. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} @@ -7,6 +7,8 @@ {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f14865e15ffewHnFwxdBlN6Eqc", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657012250, "updated": 1790657012250}, "title": "spike question", "location": {"directory": "<work>"}, "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "subagent", "resource": "*", "effect": "deny"}]}}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a219001VaS9PAEJESAl1s", "created": 1790657012250, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "slug": "misty-cactus", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "spike question", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 0, "version": 1}}}} {"type": "emit_inbound", "label": "project.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a223001QhSQm49nHEj9HB", "created": 1790657012259, "type": "project.updated", "data": {"id": "global", "canonical": "<work>", "vcs": "git", "time": {"created": 1790656063718, "updated": 1790656063718, "active": 1790657012258}, "sandboxes": []}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "text": "<any>"}}} {"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb79a22f0011nQwzuIWmBQqu7", "sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "time": {"created": 1790657012275}, "type": "user", "payload": {"text": "Before doing anything, use the question tool to ask me which color I prefer, offering the options red and blue. After I answer, reply with only the chosen color."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79a233001GMt8l3XrvXECbe", "created": 1790657012275, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb79a22f0011nQwzuIWmBQqu7", "sessionID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "item": {"type": "user", "payload": {"text": "Before doing anything, use the question tool to ask me which color I prefer, offering the options red and blue. After I answer, reply with only the chosen color."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14865e15ffewHnFwxdBlN6Eqc", "seq": 1, "version": 1}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/input.ts new file mode 100644 index 000000000000..0972f1a1e78e --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/input.ts @@ -0,0 +1,14 @@ +import { + OPENCODE2_RESTART_PROMPT, + OPENCODE2_RESTART_RECALL_PROMPT, + type OrchestratorFixtureInput, +} from "../shared.ts"; + +export function openCode2ResumeAfterRestartInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_RESTART_PROMPT }, + { type: "message", text: OPENCODE2_RESTART_RECALL_PROMPT }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/opencode_transcript.ndjson new file mode 100644 index 000000000000..71c807f28abb --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/opencode_transcript.ndjson @@ -0,0 +1,54 @@ +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_resume_after_restart", "metadata": {"source": "opencode2-spike/recordings/resume_after_restart.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f147f6435ffeBiqeQBvUukyPaj", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: a shell turn whose server is stopped mid-command (the event stream ends with no execution end), then the restarted server. After the restart `session/active` is empty, `session.get` has no outcome, and the history shows the shell call cancelled. T3 resubscribes, reads those in the order its reconnect does (active, then the history since the prompt), finds no `idle` item after the prompt, and ends the turn as interrupted. The next turn is a live 2.0.18 answer from the same session after a restart (oc2-9 orphan_b recording)."}} +{"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} +{"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} +{"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} +{"type": "emit_inbound", "label": "server.connected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb809bc8001yN3A4d64cCRPSv", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": "<any>"}} +{"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f147f6435ffeBiqeQBvUukyPaj", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657469405, "updated": 1790657469405}, "title": "spike restart_a", "location": {"directory": "<work>"}}}}} +{"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb809bdd001yy0JandzMmaPi5", "created": 1790657469405, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "slug": "happy-tiger", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "spike restart_a", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} +{"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "id": "msg_0eb809beb001jL03JQgCeojw1j", "text": "<any>"}}} +{"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb809beb001jL03JQgCeojw1j", "sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "time": {"created": 1790657469421}, "type": "user", "payload": {"text": "Run the shell command `sleep 25 && echo RESUMED` with the bash tool, then reply with its output."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb809bed001iK0ubHp9qRt69P", "created": 1790657469421, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb809beb001jL03JQgCeojw1j", "sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "item": {"type": "user", "payload": {"text": "Run the shell command `sleep 25 && echo RESUMED` with the bash tool, then reply with its output."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 1, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb809bf00013goZHLk5gbkWVV", "created": 1790657469425, "type": "session.execution.started", "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj"}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 2, "version": 1}}}} +{"type": "emit_inbound", "label": "session.instructions.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb809bfc001SxNPKX9Us1FmfA", "created": 1790657469436, "metadata": {"instructions": {"initial": true}}, "type": "session.instructions.updated", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "delta": {"core/environment": "2a1408fa4d5b2cac7fb78a0ba360ee733da4d78a82a3da470eb2c70e713b653d", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 3, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb809c00001pUL8ZpeX0A8I8v", "created": 1790657469440, "type": "session.inbox.delivered", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "inboxID": "msg_0eb809beb001jL03JQgCeojw1j"}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 4, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb80a27400126oUt7Dpg7n1gQ", "created": 1790657471092, "type": "session.step.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb809c070018it3RDEnTWfluL", "snapshot": "d580eac455a946e5bef414d21d0abbfb32eabdae", "started": 1790657469481}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 5, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb80a27d001lGj7U6nI7sGKY7", "created": 1790657471101, "type": "session.text.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0eb809c070018it3RDEnTWfluL", "ordinal": 0}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 6, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb80a2e7001n5L7h3zUl8QO6v", "created": 1790657471207, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0eb809c070018it3RDEnTWfluL", "ordinal": 0, "delta": "I'll run that command now."}}}} +{"type": "emit_inbound", "label": "session.tool.input.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb80a313001NhxJKWMGDf9kRG", "created": 1790657471251, "type": "session.tool.input.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0eb809c070018it3RDEnTWfluL", "id": "call_function_pzunphrbm0be_1", "name": "shell"}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 7, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.input.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb80a317001FpMGcrSxAF3NkW", "created": 1790657471255, "type": "session.tool.input.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0eb809c070018it3RDEnTWfluL", "id": "call_function_pzunphrbm0be_1", "text": "{\"command\": \"sleep 25 && echo RESUMED\", \"timeout\": 40000}"}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.called", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb80a31a001bVg1hUKdhVix30", "created": 1790657471258, "type": "session.tool.called", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0eb809c070018it3RDEnTWfluL", "id": "call_function_pzunphrbm0be_1", "input": {"command": "sleep 25 && echo RESUMED", "timeout": 40000}, "executed": false}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb80a31d0014PbAyBRkB0GasO", "created": 1790657471261, "type": "session.text.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0eb809c070018it3RDEnTWfluL", "ordinal": 0, "text": "I'll run that command now."}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 10, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb80a320001BsaHqPdxMyP32C", "created": 1790657471264, "type": "session.step.streamed", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0eb809c070018it3RDEnTWfluL"}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 11, "version": 1}}}} +{"type": "emit_inbound", "label": "shell.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb80a32d001bxd6ZeZODdpWIS", "created": 1790657471277, "type": "shell.created", "location": {"directory": "<work>"}, "data": {"info": {"id": "sh_0eb80a32a001qv1E0XuVCb3w7I", "status": "running", "command": "sleep 25 && echo RESUMED", "cwd": "<work>", "shell": "/bin/bash", "file": "<spike>/xdg-data/opencode/shell/global/sh_0eb80a32a001qv1E0XuVCb3w7I.out", "metadata": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj"}, "time": {"started": 1790657471274}}}}}} +{"type": "emit_inbound", "label": "session.tool.progress", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb80a32f001oYK4n15V3sTeRA", "created": 1790657471279, "type": "session.tool.progress", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0eb809c070018it3RDEnTWfluL", "id": "call_function_pzunphrbm0be_1", "metadata": {"shellID": "sh_0eb80a32a001qv1E0XuVCb3w7I"}}}}} +{"type": "runtime_exit", "status": "success"} +{"type": "expect_outbound", "label": "event.subscribe.2", "frame": {"type": "event.subscribe"}} +{"type": "emit_inbound", "label": "server.connected.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb80d471001sUVGzxfw6XUdrv", "type": "server.connected", "data": {}}}} +{"type": "expect_outbound", "label": "session.active", "frame": {"type": "session.active"}} +{"type": "emit_inbound", "label": "session.active.response", "frame": {"type": "sdk.response", "operation": "session.active", "data": {"data": {}}}} +{"type": "expect_outbound", "label": "message.list", "frame": {"type": "message.list", "input": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "order": "desc", "limit": "50"}}} +{"type": "emit_inbound", "label": "message.list.response", "frame": {"type": "sdk.response", "operation": "message.list", "data": {"data": [{"id": "msg_0eb809c070018it3RDEnTWfluL", "time": {"created": 1790657469481, "streamed": 1790657471264, "completed": 1790657473514}, "type": "assistant", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "content": [{"type": "text", "text": "I'll run that command now."}, {"type": "tool", "id": "call_function_pzunphrbm0be_1", "name": "shell", "executed": false, "state": {"status": "error", "input": {"command": "sleep 25 && echo RESUMED", "timeout": 40000}, "error": {"type": "unknown", "message": "Command cancelled"}, "metadata": {"shellID": "sh_0eb80a32a001qv1E0XuVCb3w7I"}}, "time": {"created": 1790657471251, "ran": 1790657471258, "completed": 1790657473465}}], "snapshot": {"start": "d580eac455a946e5bef414d21d0abbfb32eabdae", "end": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": []}, "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 8764, "output": 49, "reasoning": 0, "cache": {"read": 486, "write": 0}}}, {"id": "msg_0eb809beb001jL03JQgCeojw1j", "time": {"created": 1790657469440}, "text": "Run the shell command `sleep 25 && echo RESUMED` with the bash tool, then reply with its output.", "type": "user"}], "cursor": {}}}} +{"type": "expect_outbound", "label": "session.prompt.2", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "id": "msg_0f0f3d724001pB8VE4h7fMXnjc", "text": "<any>"}}} +{"type": "emit_inbound", "label": "session.prompt.response.2", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0f0f3d724001pB8VE4h7fMXnjc", "sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "time": {"created": 1790748907944}, "type": "user", "payload": {"text": "What did I last ask you to run? Answer in one short sentence."}, "delivery": "steer"}}}} +{"type": "emit_inbound", "label": "plugin.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3d9a6001PmOgj7Wz31URka", "created": 1790748907942, "type": "plugin.updated", "location": {"directory": "<work>"}, "data": {}}}} +{"type": "emit_inbound", "label": "session.inbox.enqueued.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3d9a80017VDpGi6ZDiUuQh", "created": 1790748907944, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0f0f3d724001pB8VE4h7fMXnjc", "sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "item": {"type": "user", "payload": {"text": "What did I last ask you to run? Answer in one short sentence."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 23, "version": 1}}}} +{"type": "emit_inbound", "label": "session.execution.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3d9ad001iupRRYQUWQf49N", "created": 1790748907949, "type": "session.execution.started", "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj"}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 24, "version": 1}}}} +{"type": "emit_inbound", "label": "session.tool.failed", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3d9b100149EBs3fpFvAsVp", "created": 1790748907953, "type": "session.tool.failed", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0f0f3b79e001CWehptwPIZ7ib0", "id": "call_ed28f83500c64ab791d0484f", "error": {"type": "aborted", "message": "Tool execution interrupted: shell"}, "executed": false}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 25, "version": 2}}}} +{"type": "emit_inbound", "label": "session.inbox.delivered.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3d9e5001n87LEgjs4ID7El", "created": 1790748908005, "type": "session.inbox.delivered", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "inboxID": "msg_0f0f3d724001pB8VE4h7fMXnjc"}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 26, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3e474001ZdeeA4gq1Ni9T5", "created": 1790748910708, "type": "session.step.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "agent": "build", "model": {"id": "deepseek/deepseek-v4-flash", "providerID": "openrouter", "variant": "default"}, "assistantMessageID": "msg_0f0f3d9f5001Jjo02Brpzu2UjT", "snapshot": "5ecf8511987b833d27b391b832f802775c5ca199", "started": 1790748908060}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 27, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3e4790010sU96cbI2UoHwF", "created": 1790748910713, "type": "session.reasoning.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0f0f3d9f5001Jjo02Brpzu2UjT", "ordinal": 0, "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 28, "version": 1}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3e4e0001jqY1CZ5B8pX0y2", "created": 1790748910816, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0f0f3d9f5001Jjo02Brpzu2UjT", "ordinal": 0, "delta": "The user is asking"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3e54f001nAkkq2KAAADEDM", "created": 1790748910927, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0f0f3d9f5001Jjo02Brpzu2UjT", "ordinal": 0, "delta": " what they last asked me to run. I was asked to run `sleep 25 && echo LATE2` but the tool execution was interrupted/aborted."}}}} +{"type": "emit_inbound", "label": "session.text.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3e550001BapnQCbGgEourL", "created": 1790748910928, "type": "session.text.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0f0f3d9f5001Jjo02Brpzu2UjT", "ordinal": 0}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 29, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3e5b8001ymK2CSbqaA5uYD", "created": 1790748911032, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0f0f3d9f5001Jjo02Brpzu2UjT", "ordinal": 0, "delta": "You asked me to run `sleep 25 && echo LATE2` in the foreground, but the"}}}} +{"type": "emit_inbound", "label": "session.reasoning.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3e6360015UMfWK92qCKBys", "created": 1790748911159, "type": "session.reasoning.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0f0f3d9f5001Jjo02Brpzu2UjT", "ordinal": 0, "text": "The user is asking what they last asked me to run. I was asked to run `sleep 25 && echo LATE2` but the tool execution was interrupted/aborted.", "state": {"reasoningField": "reasoning_content", "reasoningDetails": [{"type": "reasoning.text", "text": "The user is asking what they last asked me to run. I was asked to run `sleep 25 && echo LATE2` but the tool execution was interrupted/aborted.", "format": "unknown", "index": 0}]}}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 30, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3e63d001QgvPuRcWOUzy27", "created": 1790748911165, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0f0f3d9f5001Jjo02Brpzu2UjT", "ordinal": 0, "delta": " shell execution was interrupted before it completed."}}}} +{"type": "emit_inbound", "label": "session.text.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3e63e001S8Vfnuatukv4im", "created": 1790748911166, "type": "session.text.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0f0f3d9f5001Jjo02Brpzu2UjT", "ordinal": 0, "text": "You asked me to run `sleep 25 && echo LATE2` in the foreground, but the shell execution was interrupted before it completed."}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 31, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.streamed.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3e641001cZMoY6dqhC5lp3", "created": 1790748911169, "type": "session.step.streamed", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0f0f3d9f5001Jjo02Brpzu2UjT"}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 32, "version": 1}}}} +{"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3e65300147kxxhzi3oFaQs", "created": 1790748911187, "type": "session.step.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "assistantMessageID": "msg_0f0f3d9f5001Jjo02Brpzu2UjT", "finish": "stop", "rawFinish": "stop", "cost": 0.000333788, "tokens": {"input": 405, "output": 31, "reasoning": 37, "cache": {"read": 9216, "write": 0}}, "snapshot": "5ecf8511987b833d27b391b832f802775c5ca199", "files": []}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 33, "version": 1}}}} +{"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3e657001FBm8xWaxnvLtbA", "created": 1790748911191, "type": "session.usage.updated", "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "cost": 0.0016565080000000003, "tokens": {"input": 9821, "output": 36, "reasoning": 48, "cache": {"read": 9216, "write": 0}}}}}} +{"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3e6590017U3nK5ImWiF4v7", "created": 1790748911193, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj"}, "durable": {"aggregateID": "ses_f147f6435ffeBiqeQBvUukyPaj", "seq": 34, "version": 1}}}} +{"type": "runtime_exit", "status": "success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/output.ts new file mode 100644 index 000000000000..0787b1df37f3 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/output.ts @@ -0,0 +1,53 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_RESTART_PROMPT, + OPENCODE2_RESTART_RECALL_PROMPT, + projectionFor, +} from "../shared.ts"; + +/** + * The server stops mid-command and its event stream ends with no execution + * end. T3 reconnects to the restarted server, finds the session idle with no + * outcome, backfills the shell call the history shows cancelled, and ends the + * turn as interrupted. The next turn runs on the same session. + */ +export function assertOpenCode2ResumeAfterRestartOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ + result, + transcript, + runCount: 2, + runStatuses: ["interrupted", "completed"], + }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertUserMessagesInclude(projection, [ + OPENCODE2_RESTART_PROMPT, + OPENCODE2_RESTART_RECALL_PROMPT, + ]); + const [first, second] = projection.runs; + // The orphaned shell call ends as the history recorded it, not left running. + const shell = projection.turnItems.find( + (item) => item.type === "command_execution" && item.runId === first?.id, + ); + assert.deepInclude(shell, { status: "failed", input: "sleep 25 && echo RESUMED" }); + assert.deepEqual( + projection.providerTurns.map((turn) => turn.status), + ["interrupted", "completed"], + ); + const reply = projection.turnItems.find( + (item) => item.type === "assistant_message" && item.runId === second?.id, + ); + assert.include(reply?.type === "assistant_message" ? reply.text : "", "sleep 25"); + assert.lengthOf(projection.providerThreads, 1); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/opencode_transcript.ndjson index 652c0c7a9644..bbf51a68926f 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_revert", "metadata": {"source": "opencode2-spike/recordings/revert.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: two turns write reverted.txt, then the thread rolls back to the first. Stages send files: false because T3 restores its own checkpoint."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_revert", "metadata": {"source": "opencode2-spike/recordings/revert.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: two turns write reverted.txt, then the thread rolls back to the first. Stages send files: false because T3 restores its own checkpoint. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} @@ -6,6 +6,8 @@ {"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": "<any>"}} {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657118493, "updated": 1790657118493}, "title": "spike revert", "location": {"directory": "<work>"}}}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b411d0014yaniNS8WiN6zE", "created": 1790657118493, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "slug": "witty-canyon", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "spike revert", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "id": "msg_0eb7b4129001B7iaT1f3yLkR28", "text": "<any>"}}} {"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7b4129001B7iaT1f3yLkR28", "sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "time": {"created": 1790657118506}, "type": "user", "payload": {"text": "Create a file named reverted.txt containing the word ALPHA using the write tool, then reply DONE."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b412a0017VZ2v8p3ah9908", "created": 1790657118506, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb7b4129001B7iaT1f3yLkR28", "sessionID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "item": {"type": "user", "payload": {"text": "Create a file named reverted.txt containing the word ALPHA using the write tool, then reply DONE."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f1484bef1ffeGfSJ1e2FXLCaus", "seq": 1, "version": 1}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson index fd2760731e39..17b02dae519b 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_simple", "metadata": {"source": "opencode2-spike/recordings/simple.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder. The session.create expectation is T3's request for the recorded model and variant."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_simple", "metadata": {"source": "opencode2-spike/recordings/simple.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder. The session.create expectation is T3's request for the recorded model and variant. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} @@ -7,6 +7,8 @@ {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "projectID": "global", "model": {"id": "space-bunny-free", "providerID": "opencode", "variant": "high"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790656585840, "updated": 1790656585840}, "title": "spike simple", "location": {"directory": "<work>"}}}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732070001MeC12AQ1Ds6a0a", "created": 1790656585840, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "slug": "sunny-pixel", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "spike simple", "model": {"id": "space-bunny-free", "providerID": "opencode", "variant": "high"}}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 0, "version": 1}}}} {"type": "emit_inbound", "label": "project.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732078001oO5ghtqXkMav9d", "created": 1790656585848, "type": "project.updated", "data": {"id": "global", "canonical": "<work>", "vcs": "git", "time": {"created": 1790656063718, "updated": 1790656063718, "active": 1790656585847}, "sandboxes": []}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "text": "<any>"}}} {"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb732081001RntUJfRtTXOAjd", "sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "time": {"created": 1790656585859}, "type": "user", "payload": {"text": "Think carefully step by step about whether 391 is prime, showing your reasoning, then answer in one short sentence."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732083001UPoh8n1ciLGG4J", "created": 1790656585859, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb732081001RntUJfRtTXOAjd", "sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "item": {"type": "user", "payload": {"text": "Think carefully step by step about whether 391 is prime, showing your reasoning, then answer in one short sentence."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 1, "version": 1}}}} @@ -16,34 +18,34 @@ {"type": "emit_inbound", "label": "session.step.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7326930015SMmHkSszcae37", "created": 1790656587411, "type": "session.step.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "agent": "build", "model": {"id": "space-bunny-free", "providerID": "opencode", "variant": "high"}, "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790656585925}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 5, "version": 1}}}} {"type": "emit_inbound", "label": "session.reasoning.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732699001FC5efnDXzkrBV3", "created": 1790656587417, "type": "session.reasoning.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 6, "version": 1}}}} {"type": "emit_inbound", "label": "session.reasoning.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732702001XkfhcVQWas7guy", "created": 1790656587522, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "Check divisibility up to sqrt("}}}} -{"type": "emit_inbound", "label": "session.reasoning.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73277b001U6tDaZPzh5G8Ub", "created": 1790656587643, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "391) ≈ 19.77."}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73277b001U6tDaZPzh5G8Ub", "created": 1790656587643, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "391) \u2248 19.77."}}}} {"type": "emit_inbound", "label": "session.reasoning.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732805001zRua1yhKskE6du", "created": 1790656587781, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " Primes: 2,3,5,7,"}}}} {"type": "emit_inbound", "label": "session.reasoning.delta.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73286e001XXnCtQeutby7Jh", "created": 1790656587886, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "11,13,17,19.\n\n391 is"}}}} {"type": "emit_inbound", "label": "session.reasoning.delta.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7328e8001vpkz0eDsHflXmv", "created": 1790656588008, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " odd, not div by 2"}}}} {"type": "emit_inbound", "label": "session.reasoning.delta.6", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732955001YnC9O9FAgIagoL", "created": 1790656588118, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": ". 3+9+"}}}} -{"type": "emit_inbound", "label": "session.reasoning.delta.7", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732aaa001b129VgQ4aT7avM", "created": 1790656588458, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "1=13, not div by 3. Not by 5. 7: 7×55=385, remainder 6. 11: 11×"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.7", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732aaa001b129VgQ4aT7avM", "created": 1790656588458, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "1=13, not div by 3. Not by 5. 7: 7\u00d755=385, remainder 6. 11: 11\u00d7"}}}} {"type": "emit_inbound", "label": "session.reasoning.delta.8", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732b29001AMm839z4dDYXHE", "created": 1790656588585, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "35=385, remainder 6. 13: "}}}} -{"type": "emit_inbound", "label": "session.reasoning.delta.9", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732ba0001Ivdi55eX7RWaIN", "created": 1790656588704, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "13×30=390, remainder 1. "}}}} -{"type": "emit_inbound", "label": "session.reasoning.delta.10", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732c1f001l5voAVE8zZty1G", "created": 1790656588832, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "17: 17×23=391!"}}}} -{"type": "emit_inbound", "label": "session.reasoning.delta.11", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732c95001xtU1t86iQ32FcX", "created": 1790656588949, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " 17×23 = 391. Yes"}}}} -{"type": "emit_inbound", "label": "session.reasoning.delta.12", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732d3b001F922FhdbWHFFh9", "created": 1790656589115, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": ": 17×23 = 340 +"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.9", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732ba0001Ivdi55eX7RWaIN", "created": 1790656588704, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "13\u00d730=390, remainder 1. "}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.10", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732c1f001l5voAVE8zZty1G", "created": 1790656588832, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "17: 17\u00d723=391!"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.11", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732c95001xtU1t86iQ32FcX", "created": 1790656588949, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " 17\u00d723 = 391. Yes"}}}} +{"type": "emit_inbound", "label": "session.reasoning.delta.12", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732d3b001F922FhdbWHFFh9", "created": 1790656589115, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": ": 17\u00d723 = 340 +"}}}} {"type": "emit_inbound", "label": "session.reasoning.delta.13", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732db1001Llbce6UsLrsMtr", "created": 1790656589234, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " 51 = 391. So "}}}} {"type": "emit_inbound", "label": "session.reasoning.delta.14", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732de6001fDagd9vuCaT77g", "created": 1790656589286, "type": "session.reasoning.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "391 is composite."}}}} {"type": "emit_inbound", "label": "session.text.started", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732de7001zYvkQcsI5WOzgG", "created": 1790656589287, "type": "session.text.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 7, "version": 1}}}} -{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732e50001GdabW0WKYVl32T", "created": 1790656589392, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "√391 ≈ 19.8,"}}}} +{"type": "emit_inbound", "label": "session.text.delta", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732e50001GdabW0WKYVl32T", "created": 1790656589392, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "\u221a391 \u2248 19.8,"}}}} {"type": "emit_inbound", "label": "session.text.delta.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732ed2001IBPZhAC1kx4d1v", "created": 1790656589522, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " so I need to test primes "}}}} {"type": "emit_inbound", "label": "session.text.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732f38001E0KjfQvDXHZ7h1", "created": 1790656589624, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "2, 3, 5, 7, "}}}} {"type": "emit_inbound", "label": "session.text.delta.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb732fad001JzcESOkjYwU5ik", "created": 1790656589741, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "11, 13, 17, 19: it's"}}}} {"type": "emit_inbound", "label": "session.text.delta.5", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73301c001UBnr5x154Roys2", "created": 1790656589852, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " odd, digits sum to 13, doesn't"}}}} -{"type": "emit_inbound", "label": "session.text.delta.6", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73308e001sYejhe1LAMtvcI", "created": 1790656589966, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " end in 0/5, and 7×"}}}} +{"type": "emit_inbound", "label": "session.text.delta.6", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73308e001sYejhe1LAMtvcI", "created": 1790656589966, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " end in 0/5, and 7\u00d7"}}}} {"type": "emit_inbound", "label": "session.text.delta.7", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7331030012XzmUwjcWGmPUe", "created": 1790656590084, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "55=385, 11"}}}} -{"type": "emit_inbound", "label": "session.text.delta.8", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7331700018ST8kVeG36WRUV", "created": 1790656590192, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "×35=385, 13×30=390 leave remain"}}}} -{"type": "emit_inbound", "label": "session.text.delta.9", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7331df001Nm2KLtcVET4KlU", "created": 1790656590304, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "ders — but 17×23 = 391"}}}} +{"type": "emit_inbound", "label": "session.text.delta.8", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7331700018ST8kVeG36WRUV", "created": 1790656590192, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "\u00d735=385, 13\u00d730=390 leave remain"}}}} +{"type": "emit_inbound", "label": "session.text.delta.9", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7331df001Nm2KLtcVET4KlU", "created": 1790656590304, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": "ders \u2014 but 17\u00d723 = 391"}}}} {"type": "emit_inbound", "label": "session.text.delta.10", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb733251001HkPIEuVZxWlLIU", "created": 1790656590417, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " exactly, so the search"}}}} {"type": "emit_inbound", "label": "session.text.delta.11", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7332bd001f1FZxccg5aNmhq", "created": 1790656590525, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " stops there.\n\n391 is not prime: it's the"}}}} -{"type": "emit_inbound", "label": "session.text.delta.12", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73333d001mIrQbkY6rbRMi3", "created": 1790656590654, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " product 17 × 23."}}}} -{"type": "emit_inbound", "label": "session.reasoning.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7333720010sLSSNU5xRXUwa", "created": 1790656590707, "type": "session.reasoning.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "text": "Check divisibility up to sqrt(391) ≈ 19.77. Primes: 2,3,5,7,11,13,17,19.\n\n391 is odd, not div by 2. 3+9+1=13, not div by 3. Not by 5. 7: 7×55=385, remainder 6. 11: 11×35=385, remainder 6. 13: 13×30=390, remainder 1. 17: 17×23=391! 17×23 = 391. Yes: 17×23 = 340 + 51 = 391. So 391 is composite.", "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 8, "version": 1}}}} -{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb733378001jAPLwPeycQ4M5d", "created": 1790656590712, "type": "session.text.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "text": "√391 ≈ 19.8, so I need to test primes 2, 3, 5, 7, 11, 13, 17, 19: it's odd, digits sum to 13, doesn't end in 0/5, and 7×55=385, 11×35=385, 13×30=390 leave remainders — but 17×23 = 391 exactly, so the search stops there.\n\n391 is not prime: it's the product 17 × 23."}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 9, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.12", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73333d001mIrQbkY6rbRMi3", "created": 1790656590654, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "delta": " product 17 \u00d7 23."}}}} +{"type": "emit_inbound", "label": "session.reasoning.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7333720010sLSSNU5xRXUwa", "created": 1790656590707, "type": "session.reasoning.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "text": "Check divisibility up to sqrt(391) \u2248 19.77. Primes: 2,3,5,7,11,13,17,19.\n\n391 is odd, not div by 2. 3+9+1=13, not div by 3. Not by 5. 7: 7\u00d755=385, remainder 6. 11: 11\u00d735=385, remainder 6. 13: 13\u00d730=390, remainder 1. 17: 17\u00d723=391! 17\u00d723 = 391. Yes: 17\u00d723 = 340 + 51 = 391. So 391 is composite.", "state": {"reasoningField": "reasoning_content"}}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 8, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb733378001jAPLwPeycQ4M5d", "created": 1790656590712, "type": "session.text.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "ordinal": 0, "text": "\u221a391 \u2248 19.8, so I need to test primes 2, 3, 5, 7, 11, 13, 17, 19: it's odd, digits sum to 13, doesn't end in 0/5, and 7\u00d755=385, 11\u00d735=385, 13\u00d730=390 leave remainders \u2014 but 17\u00d723 = 391 exactly, so the search stops there.\n\n391 is not prime: it's the product 17 \u00d7 23."}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 9, "version": 1}}}} {"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73337f001Y6nJkjv1tXkqGt", "created": 1790656590719, "type": "session.step.streamed", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT"}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 10, "version": 1}}}} {"type": "emit_inbound", "label": "session.step.ended", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb733390001vg5GRCuNzMFbCA", "created": 1790656590736, "type": "session.step.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "assistantMessageID": "msg_0eb7320a9001vve3OV5uNi2HRT", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 8701, "output": 113, "reasoning": 147, "cache": {"read": 489, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "seq": 11, "version": 1}}}} {"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb733396001Ke5QhqiR5CdvP6", "created": 1790656590742, "type": "session.usage.updated", "data": {"sessionID": "ses_f148cdfa1ffeIFwbC0FCx0fKpk", "cost": 0, "tokens": {"input": 8701, "output": 113, "reasoning": 147, "cache": {"read": 489, "write": 0}}}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/opencode_transcript.ndjson index dc9c5ca63dca..77dc36fbc42b 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_skill/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_skill", "metadata": {"source": "oc2-8 live recording skill_t3.ndjson (2.0.18, openrouter/deepseek/deepseek-v4-flash)", "capturedAt": "2026-09-29", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary: a prompt naming `$greet`, a workspace skill (.opencode/skills/greet/SKILL.md), so T3 reads the directory's skills and attaches it with prompt{skills}. Machine-wide skills are dropped from the recorded list."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_skill", "metadata": {"source": "oc2-8 live recording skill_t3.ndjson (2.0.18, openrouter/deepseek/deepseek-v4-flash)", "capturedAt": "2026-09-29", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary: a prompt naming `$greet`, a workspace skill (.opencode/skills/greet/SKILL.md), so T3 reads the directory's skills and attaches it with prompt{skills}. Machine-wide skills are dropped from the recorded list. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "deepseek/deepseek-v4-flash", "modelID": "deepseek/deepseek-v4-flash", "providerID": "openrouter", "family": "deepseek-flash", "name": "DeepSeek V4 Flash", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openrouter", "settings": {"baseURL": "https://openrouter.ai/api/v1"}, "headers": {"HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [{"id": "none", "settings": {"reasoning": {"enabled": false}}}, {"id": "high", "settings": {"reasoning": {"effort": "high"}}}, {"id": "xhigh", "settings": {"reasoning": {"effort": "xhigh"}}}], "time": {"released": 1776988800000}, "cost": [{"input": 0.14, "output": 0.28, "cache": {"read": 0.028, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "output": 131072}}]}}} @@ -6,6 +6,8 @@ {"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": "<any>"}} {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "projectID": "global", "model": {"id": "deepseek/deepseek-v4-flash", "providerID": "openrouter", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790747099267, "updated": 1790747099267}, "title": "oc2-8 skill_t3", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}], "location": {"directory": "<work>"}}}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0d84083001QSHajGz7TcpzgV", "created": 1790747099267, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "slug": "happy-rocket", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "oc2-8 skill_t3", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}], "model": {"id": "deepseek/deepseek-v4-flash", "providerID": "openrouter"}}, "durable": {"aggregateID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "skill.list", "frame": {"type": "skill.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "skill.list.response", "frame": {"type": "sdk.response", "operation": "skill.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "opencode", "name": "OpenCode", "description": "Use this skill for any question about OpenCode itself, including how OpenCode works, using or configuring it, migrating from V1 to V2, troubleshooting it, developing plugins or integrations, using the OpenCode SDK, clients, server, or API, and contributing to the OpenCode codebase. Also use it for OpenCode agents, commands, skills, tools, permissions, MCP servers, providers, models, themes, keybinds, formatters, the CLI, TUI, desktop app, and web app.", "path": "/builtin/opencode.md", "content": "# OpenCode\n\nUse this guide as the starting point for work involving OpenCode itself. It\ncovers the core concepts needed to configure and customize OpenCode, extend it\nwith plugins, and build integrations with the OpenCode SDK, clients, and API.\n\nFull documentation is available at <https://opencode.ai/v2/docs/>. This overview is\nonly an index of core concepts. Before answering a question about a topic below,\nfetch the URL named in that section and use the full page as the source of\ntruth. Follow links from that page when the question needs more detail. Fetch\n<https://opencode.ai/v2/docs/> first when you need to discover the relevant\ndocumentation page.\n\nA machine-readable documentation index is available at\n<https://opencode.ai/v2/llms.txt>.\n\n## Version policy\n\nAlways answer for OpenCode V2 unless the user explicitly asks about V1,\nlegacy OpenCode, or migrating from V1.\n\nUse only <https://opencode.ai/v2/docs/> documentation as the source of truth for V2.\nDo not use <https://opencode.ai/docs/>, which documents V1, and do not use\ngeneral web search to resolve a V2 documentation question when the V2 docs or\nlinked pages cover it. The schema served from\n<https://opencode.ai/config.json> may describe V1 even though V2 configuration\nfiles include that URL for editor integration. Never use it to infer V2 field\nnames or shapes. If V2 documentation is missing or contradictory, state the\nuncertainty or ask for clarification instead of falling back to V1.\n\nV1 documentation and syntax may be consulted only when the user explicitly\nasks about V1 or when needed as migration input. Outputs and recommendations\nmust still use V2 unless the user specifically requests a V1 result.\n\n## [CLI](https://opencode.ai/v2/docs/cli)\n\nFor questions about the terminal interface, command-line invocation, `run`,\n`mini`, terminal providers, or other CLI behavior, fetch the\n[CLI guide](https://opencode.ai/v2/docs/cli) and the relevant page linked from\nthat section.\n\nCLI and TUI preferences are separate from OpenCode's server and project\nconfiguration. They live in the global `~/.config/opencode/cli.json`, or\n`$XDG_CONFIG_HOME/opencode/cli.json` when `XDG_CONFIG_HOME` is set. There is no\nproject-local CLI configuration. Set `OPENCODE_CLI_CONFIG_CONTENT` to merge\ninline JSON over the global settings. Most preferences can also be changed from\nthe TUI by pressing `Ctrl+P` and selecting **Open settings**.\n\n### [Settings](https://opencode.ai/v2/docs/cli/config)\n\nFetch the full [CLI settings reference](https://opencode.ai/v2/docs/cli/config)\nbefore editing `cli.json`. It documents every terminal-only setting, accepted\nvalues, and examples, including themes, input, sessions, tabs, diffs, alerts,\nMini, keybindings, terminal plugins, and debugging. Do not put these settings\nin `opencode.json(c)`.\n\n### [Keybinds](https://opencode.ai/v2/docs/cli/keybinds)\n\nConfigure keybindings under `keybinds` in `cli.json`. The leader key is the\n`keybinds.leader` entry; leader timing is configured separately under\n`leader.timeout`. Bindings can use a string, an array of strings, or an object\nwhen event behavior such as `preventDefault` is required. Disable a binding\nwith `\"none\"` or `false`.\n\nNever guess a command ID, default binding, or accepted key syntax. Fetch the\nfull [keybind reference](https://opencode.ai/v2/docs/cli/keybinds), which lists\nthe current IDs and defaults, before answering or editing a binding.\n\n## [OpenCode configuration](https://opencode.ai/v2/docs/config)\n\nOpenCode's server and project configuration uses JSON or JSONC. Include the\npublished schema so the user's editor can validate fields and provide\nautocomplete:\n\n```jsonc\n{\n \"$schema\": \"https://opencode.ai/config.json\",\n}\n```\n\nGlobal configuration lives at `~/.config/opencode/opencode.json(c)` and applies\nto every project for that user. Project configuration can live in any directory\nas `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages\nin a monorepo.\n\nDuring ordinary project discovery, OpenCode searches the current Location\ndirectory and every ancestor through the filesystem root, including directories\nabove the detected project or repository root. It merges direct\n`opencode.json(c)` files from the farthest ancestor to the current directory,\nthen does the same for `.opencode/opencode.json(c)` files. This means every\ndiscovered `.opencode` config overrides every discovered direct config. Global\nfilesystem configuration has lower precedence than these discovered documents.\n\nCommon configuration fields include `model`, `default_agent`, `permissions`,\n`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,\n`references`, `formatter`, and `lsp`.\n\nThis configuration is distinct from `cli.json`. Use the\n[CLI settings reference](https://opencode.ai/v2/docs/cli/config) for terminal\npreferences, especially themes and keybindings.\n\nDo not guess field names or shapes. Fetch the V2 configuration guide and its\nlinked topic guide as the source of truth, and preserve unrelated settings when\nediting an existing file. Keep the published `$schema` URL in configuration\nexamples, but do not fetch it to determine the V2 configuration shape.\n\nSee the [full configuration guide](https://opencode.ai/v2/docs/config) for\nevery field, examples, config locations, and links to dedicated feature guides.\n\n## [MCP servers](https://opencode.ai/v2/docs/mcp-servers)\n\nConfigure MCP servers under `mcp.servers`. Prefer the CLI because it preserves\nunrelated configuration. Use `--global` when the user asks to set up a service\nfor themselves without limiting it to the current project; omit it when they\nexplicitly want project-local configuration.\n\n```sh\nopencode mcp add <name> --global --url <remote-url>\nopencode mcp list\n```\n\nRemote servers use OAuth by default. If `mcp list` reports that a server needs\nauthentication, tell the user to run `/mcps`, select the server, and sign in.\nDo not run `opencode mcp auth` through the shell tool: it starts an interactive\nflow whose authorization link can be hidden in background process output.\nUse the user-facing MCP interface instead.\n\nReport the server as configured but awaiting sign-in until its connection\nstatus confirms it is connected.\n\nOAuth credentials are stored outside the OpenCode configuration. Do not ask for or\nstore an API key when the server supports OAuth. Use header-based credentials\nonly when OAuth is unavailable or the user explicitly requires them, and use an\nenvironment substitution such as `{env:MCP_API_KEY}` instead of writing a\nsecret into configuration.\n\n## [V1 to V2 migration](https://opencode.ai/v2/docs/migrate-v1)\n\nFor any request to migrate OpenCode configuration, agents, commands, skills,\nplugins, integrations, or other behavior from V1 to V2, read the full\n[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In\nthe repository, its source is `services/www/src/docs/content/migrate-v1.mdx`.\n\nV1 config files and `.opencode/` definitions are intended to remain compatible.\nThe only intentional breaking changes are the server API and plugin API. Native\nV2 config uses more ergonomic shapes, but conversion is optional. When the user\nrequests conversion, inspect the complete configuration, preserve behavior and\nunrelated settings, and apply only the relevant migrations from the guide. For\nplugin migrations, fetch and follow both the migration guide and the full\n[plugins guide](https://opencode.ai/v2/docs/build/plugins). If non-API V1\nfunctionality fails in V2, use the `report` skill to file it as a compatibility\nbug.\n\n## [Plugins](https://opencode.ai/v2/docs/build/plugins)\n\nFor questions about creating, configuring, loading, publishing, or migrating\nplugins, fetch the full [plugins guide](https://opencode.ai/v2/docs/build/plugins)\nbefore answering. Refer to this guide when the user wants to build a plugin. It\ncovers hooks, transforms, tools, plugin context capabilities, and package\nentrypoints. Plugins can also extend the TUI; for those, fetch the\n[CLI plugin guide](https://opencode.ai/v2/docs/build/plugins/cli).\nFor custom methods and events shared with other plugins or clients, fetch the\n[RPC guide](https://opencode.ai/v2/docs/build/plugins/rpc).\n\n## [Service](https://opencode.ai/v2/docs/troubleshooting#check-the-background-service)\n\nOpenCode uses a client-server architecture. Interfaces such as the TUI connect\nto a background OpenCode service, which owns sessions, configuration, plugins,\npermissions, and tool execution.\n\nOpenCode normally discovers or starts the shared background service\nautomatically. If the service is stuck or unhealthy, restart it:\n\n```sh\nopencode service restart\n```\n\nCheck its status after restarting:\n\n```sh\nopencode service status\n```\n\n## [API](https://opencode.ai/v2/docs/api)\n\nOpenCode exposes an HTTP API from its server. The API is described by an\nOpenAPI document available from the running server at `/openapi.json`.\n\nUse OpenCode's built-in `api` command for local requests. It uses the same\ndiscovery and authentication flow as the TUI and may start the background\nservice when no compatible healthy service is available. It accepts either an\nHTTP method and path or an OpenAPI operation ID.\n\nCall an endpoint with an HTTP method and path:\n\n```sh\nopencode api get /api/info\n```\n\nPass a request body with `--data` or `-d`, and additional headers with\n`--header` or `-H`:\n\n```sh\nopencode api post /api/example --data '{\"key\":\"value\"}'\nopencode api get /api/example --header 'X-Example:value'\n```\n\nRequest bodies default to `Content-Type: application/json`. When OpenCode is\nconnected to an explicit server instead of its managed background service, use\nthe same configured server and authentication context rather than constructing\nan unauthenticated request separately.\n\nSee the [full API reference](https://opencode.ai/v2/docs/api) for available\nendpoints, parameters, request bodies, and response schemas. The\nraw [OpenAPI specification](https://opencode.ai/v2/openapi.json) is also\navailable for code generation and other tooling.\n\n## [Client](https://opencode.ai/v2/docs/build/client)\n\nFor questions about connecting an application to OpenCode over the network,\nfetch the full [client guide](https://opencode.ai/v2/docs/build/client) before\nanswering.\n\n`@opencode/client` is the generated TypeScript client for the OpenCode HTTP\nAPI. Its methods and types come from the same contract as the API reference.\nThe default entrypoint exposes Promise-based resource clients and async\niterables for streaming endpoints. The `@opencode/client/effect` entrypoint\nexposes typed Effects, Streams, and decoded OpenCode schema values. Its\n`Service` API can discover, start, stop, and authenticate with the local\nbackground service from a Node application.\n\n## [SDK](https://opencode.ai/v2/docs/build/sdk)\n\nFor questions about embedding OpenCode directly in an application, fetch the\nfull [SDK guide](https://opencode.ai/v2/docs/build/sdk) before answering. The SDK\nhosts OpenCode in the application without opening an HTTP listener.\n\nUse the [Effect SDK guide](https://opencode.ai/v2/docs/build/sdk/effect) for\nEffect applications. For Cloudflare Durable Objects, use the\n[Cloudflare SDK guide](https://opencode.ai/v2/docs/build/sdk/cloudflare).\n\n## [Troubleshooting](https://opencode.ai/v2/docs/troubleshooting)\n\nOpenCode runs a client and a background server. Start by determining whether a\nproblem belongs to the client, the shared server, or one project.\n\n- Check the service with `opencode service status` and verify the API with\n `opencode api get /api/info`.\n- Compare with `opencode --standalone`, which runs the TUI with a private\n server, to isolate shared-service issues.\n- Inspect `~/.local/share/opencode/log/opencode.log`. Filter `role=cli` for\n client startup and `role=server` for sessions, providers, plugins,\n permissions, and tools.\n- Run one reproduction with `OPENCODE_LOG_LEVEL=DEBUG` when normal logs are not\n sufficient.\n- Do not delete or edit the database, service registration, or service config\n while diagnosing a problem. Back up persistent data before inspecting it\n with external tools.\n- Redact API keys, authorization headers, prompts, file contents, and other\n sensitive data before sharing diagnostics.\n\nSee the [full troubleshooting guide](https://opencode.ai/v2/docs/troubleshooting)\nfor service lifecycle commands, API inspection, log locations, explicit server\nconnections, issue-reporting details, and local development paths.\n"}, {"id": "report", "name": "Report", "description": "Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI.", "path": "/builtin/report.md", "content": "<!--\n Built-in skill. Name and description are registered in code at\n packages/core/src/plugin/skill.ts. The body below becomes the skill's\n content.\n-->\n\n# Report an opencode Issue\n\nUse this skill when the user wants to report an opencode issue or bug. Your job\nis to turn the user's problem into a useful GitHub issue with standard\ndiagnostics plus the context needed to reproduce and resolve it.\n\n## Workflow\n\n1. Collect the standard diagnostics below.\n2. Ask only for missing details that are necessary to reproduce or understand\n impact.\n3. Draft the issue in the standard format below.\n4. Publish it with GitHub CLI after the user confirms the title and body.\n\nDo not publish an issue without user confirmation. If GitHub CLI is not\ninstalled or not authenticated, explain the blocker and provide the exact issue\ntitle/body for the user.\n\n## Standard Diagnostics\n\nCollect these values when possible:\n\n- opencode version: run `opencode --version`.\n- Operating system: run `uname -a` on Unix-like systems, or `ver` on Windows.\n- Terminal: inspect `$TERM`, `$TERM_PROGRAM`, `$COLORTERM`, and any obvious\n terminal app context the user provides.\n- Shell: inspect `$SHELL` on Unix-like systems, or `%COMSPEC%`/`$ComSpec` on\n Windows when relevant.\n- Install/channel context: include whether this appears to be local, dev, beta,\n or release if the version output or environment reveals it.\n- Active plugins: inspect opencode config for configured plugins when possible.\n Check likely config locations such as `opencode.json`, `opencode.jsonc`,\n `.opencode/opencode.json`, and `~/.config/opencode/opencode.json`. Record\n configured plugin entries, local plugin files under `.opencode/plugin/` or\n `.opencode/plugins/`, and note if plugin status could not be determined.\n\nIf a diagnostic command fails, include `Unavailable` with the reason instead of\nguessing.\n\n## User-Specific Context\n\nCapture the details that make the issue actionable:\n\n- What the user was trying to do.\n- What happened.\n- What the user expected to happen.\n- Reproduction steps, ideally minimal and numbered.\n- Relevant logs, stack traces, screenshots, terminal output, or config snippets.\n- Whether the issue is reproducible consistently, intermittently, or only once.\n- Recent changes that may be related, such as updating opencode, changing\n config, installing a plugin, changing terminal, or switching workspace.\n- Workarounds tried and whether they helped.\n\nAvoid pasting secrets. Redact tokens, API keys, private URLs, usernames, and\nproject-specific confidential data unless the user explicitly says it is safe.\n\n## Issue Format\n\nUse this exact structure unless the repository issue template requires\notherwise:\n\n```markdown\n## Summary\n\n<!-- One or two sentences describing the bug and impact. -->\n\n## Environment\n\n- opencode version: <!-- value or Unavailable: reason -->\n- OS: <!-- value or Unavailable: reason -->\n- Terminal: <!-- value or Unavailable: reason -->\n- Shell: <!-- value or Unavailable: reason -->\n- Install/channel: <!-- value or Unavailable: reason -->\n- Active plugins: <!-- list, none found, or Unavailable: reason -->\n\n## Reproduction\n\n1. <!-- step -->\n2. <!-- step -->\n3. <!-- step -->\n\n## Expected Behavior\n\n<!-- What should have happened. -->\n\n## Actual Behavior\n\n<!-- What happened instead. Include exact errors when available. -->\n\n## Additional Context\n\n<!-- Logs, config snippets, screenshots, frequency, workarounds, related notes. -->\n```\n\nKeep the title short and searchable. Prefer the form:\n\n```text\n<area>: <specific failure or symptom>\n```\n\nExamples: `tui: skills dialog crashes outside location provider`,\n`cli: local service config writes release filename`.\n\n## Publishing With GitHub CLI\n\nUse GitHub CLI from the repository checkout when available:\n\n```sh\ngh issue create --title \"<title>\" --body-file <file>\n```\n\nWrite the body to a temporary markdown file first so quoting, newlines, logs,\nand code fences are preserved. If the issue belongs in a specific repository,\nuse `--repo owner/name`. If labels are obvious and the repo accepts them, add\n`--label bug`; otherwise omit labels rather than guessing.\n\nAfter publishing, report the created issue URL to the user and mention any\ndiagnostics that were unavailable.\n\n\n## Runtime Diagnostics Snapshot\n\nThese values were captured when the built-in report skill was registered. Verify them before publishing.\n\n- opencode version: 2.0.18\n- install/channel: latest\n- OS: Linux 6.12.107+deb13-amd64 (linux x64)\n- Terminal: Unavailable: terminal environment variables are not set\n- Shell: /bin/bash\n- Active plugins: None found in config"}, {"id": "greet4", "name": "greet4", "description": "Test skill greet4.", "path": "<work>/.claude/skills/greet4/SKILL.md", "content": "Begin reply with MANGO.\n"}, {"id": "greet3", "name": "greet3", "description": "Test skill greet3.", "path": "<work>/.agents/skills/greet3/SKILL.md", "content": "Begin reply with MANGO.\n"}, {"id": "greet2", "name": "greet2", "description": "Test skill greet2.", "path": "<work>/.opencode/skill/greet2/SKILL.md", "content": "Begin reply with MANGO.\n"}, {"id": "greet", "name": "greet", "description": "Greets the user with the secret word MANGO.", "path": "<work>/.opencode/skills/greet/SKILL.md", "content": "When this skill is active, begin your reply with the exact word MANGO.\n"}]}}} {"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f0f27bf90ffeEzhR6OIvZo6Y1l", "text": "<any>", "skills": [{"id": "greet"}]}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/opencode_transcript.ndjson index 1e1ac6f30e72..e5fee2395429 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_subagent", "metadata": {"source": "opencode2-spike/recordings/subagent.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_subagent", "metadata": {"source": "opencode2-spike/recordings/subagent.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} @@ -6,6 +6,8 @@ {"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": "<any>"}} {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657025663, "updated": 1790657025663}, "title": "spike subagent", "location": {"directory": "<work>"}}}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79d67f001cKJ38bhZTN6LOd", "created": 1790657025663, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "slug": "tidy-wizard", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "spike subagent", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "text": "<any>"}}} {"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb79d68b001Au0Ty7w09VhWVs", "sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "time": {"created": 1790657025676}, "type": "user", "payload": {"text": "Use the subagent tool to delegate to the explore subagent with the prompt: 'List the files in the current directory and report their names.' Wait for it, then summarize its answer in one line."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb79d68c001ctcXXGdXOjF4Ss", "created": 1790657025676, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb79d68b001Au0Ty7w09VhWVs", "sessionID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "item": {"type": "user", "payload": {"text": "Use the subagent tool to delegate to the explore subagent with the prompt: 'List the files in the current directory and report their names.' Wait for it, then summarize its answer in one line."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14862990ffe1Cj3Y1mn0nJq0R", "seq": 1, "version": 1}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson index c4efb89a327c..11b79b6d31f1 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_switch", "metadata": {"source": "opencode2-spike/recordings/switch.ndjson", "capturedAt": "2026-09-28", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: a plan-mode turn after session.switchAgent(plan), then session.switchAgent(build) and session.switchModel before the next prompt. OpenCode queues its own Plan mode reminders. Replayed by OpenCode2OrchestratorV2.integration.test.ts. T3 reopens the session for the model change, so its session.get (answered as the recording's final read) sits before the second switch. The model list is the tool_call recording's plus the switched-to model, so no turn re-reads it."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_switch", "metadata": {"source": "opencode2-spike/recordings/switch.ndjson", "capturedAt": "2026-09-28", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: a plan-mode turn after session.switchAgent(plan), then session.switchAgent(build) and session.switchModel before the next prompt. OpenCode queues its own Plan mode reminders. Replayed by OpenCode2OrchestratorV2.integration.test.ts. T3 reopens the session for the model change, so its session.get (answered as the recording's final read) sits before the second switch. The model list is the tool_call recording's plus the switched-to model, so no turn re-reads it. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}, {"id": "nemotron-3.5-lightning-free", "modelID": "nemotron-3.5-lightning-free", "providerID": "opencode", "family": "nemotron", "name": "Nemotron 3.5 Lightning Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} @@ -12,6 +12,8 @@ {"type": "expect_outbound", "label": "session.switchAgent", "frame": {"type": "session.switchAgent", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "agent": "plan"}}} {"type": "emit_inbound", "label": "session.switchAgent.response", "frame": {"type": "sdk.response", "operation": "session.switchAgent", "data": null}} {"type": "emit_inbound", "label": "session.agent.selected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7edeb2001MCL22KdHxuErJq", "created": 1790657355442, "type": "session.agent.selected", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "agent": "plan"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 1, "version": 1}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "text": "<any>"}}} {"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7edec5001SuzE8LdQ6S95VC", "sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "time": {"created": 1790657355463}, "type": "user", "payload": {"text": "Create a file named plan_probe.txt containing HI using the write tool."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7edebb001yltYBTG8qR3qu5", "created": 1790657355452, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb7edeba001PfAzVPoYsGHgb2", "sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "item": {"type": "synthetic", "payload": {"text": "<system-reminder>\nYou are in Plan mode. Discuss the plan with the user directly in the conversation. Do not create or update plan files unless the user explicitly asks you to; when they do, write them only in:\n<home>/.opencode/plan\n\nDo not modify any other files or ask a subagent to do so.\n\nYou remain in Plan mode until the user switches agents. If the user asks you to implement changes, do not do so. Tell them they need to switch agents.\n</system-reminder>"}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 2, "version": 1}}}} @@ -51,7 +53,7 @@ {"type": "emit_inbound", "label": "session.execution.succeeded", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef75001z9Sy13uGDVqV3F", "created": 1790657359733, "type": "session.execution.succeeded", "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 15, "version": 1}}}} {"type": "expect_outbound", "label": "session.get", "frame": {"type": "session.get", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP"}}} {"type": "emit_inbound", "label": "session.get.response", "frame": {"type": "sdk.response", "operation": "session.get", "data": {"data": {"id": "ses_f14812197ffeCbw641wM2r3ZmP", "projectID": "global", "agent": "plan", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 8910, "output": 271, "reasoning": 0, "cache": {"read": 384, "write": 0}}, "outcome": "succeeded", "time": {"created": 1790657355410, "updated": 1790657355463}, "title": "spike switch", "location": {"directory": "<work>"}, "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "subagent", "resource": "*", "effect": "deny"}]}}}} -{"type": "expect_outbound", "label": "session.update", "frame": {"type": "session.update", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}]}}} +{"type": "expect_outbound", "label": "session.update", "frame": {"type": "session.update", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "t3-code-*", "resource": "*", "effect": "deny"}, {"action": "t3-code-thread_opencode2_switch_*", "resource": "*", "effect": "allow"}]}}} {"type": "emit_inbound", "label": "session.update.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}} {"type": "expect_outbound", "label": "session.switchAgent.2", "frame": {"type": "session.switchAgent", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "agent": "build"}}} {"type": "emit_inbound", "label": "session.switchAgent.response.2", "frame": {"type": "sdk.response", "operation": "session.switchAgent", "data": null}} @@ -60,6 +62,8 @@ {"type": "expect_outbound", "label": "session.switchModel", "frame": {"type": "session.switchModel", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "model": {"providerID": "opencode", "id": "nemotron-3.5-lightning-free"}}}} {"type": "emit_inbound", "label": "session.switchModel.response", "frame": {"type": "sdk.response", "operation": "session.switchModel", "data": null}} {"type": "emit_inbound", "label": "session.model.selected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef8d001Yy5fmSDr6nQ8OS", "created": 1790657359757, "type": "session.model.selected", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "model": {"id": "nemotron-3.5-lightning-free", "providerID": "opencode"}, "previous": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 18, "version": 1}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put.2", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response.2", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt.2", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "text": "<any>"}}} {"type": "emit_inbound", "label": "session.prompt.response.2", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb7eef95001lHN9WhCH6Q9l6w", "sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "time": {"created": 1790657359766}, "type": "user", "payload": {"text": "Reply exactly SWITCHED."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued.4", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef96001Adumk9iclCkzRM", "created": 1790657359766, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb7eef95001lHN9WhCH6Q9l6w", "sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "item": {"type": "user", "payload": {"text": "Reply exactly SWITCHED."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 19, "version": 1}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/opencode_transcript.ndjson index 2519ef8934a1..64e5eff7d73a 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_tool_call", "metadata": {"source": "opencode2-spike/recordings/tool_call.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f148ca2deffeJcwCnRQtb0YFNX", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_tool_call", "metadata": {"source": "opencode2-spike/recordings/tool_call.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f148ca2deffeJcwCnRQtb0YFNX", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": "<any>"}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": "<work>"}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} @@ -6,6 +6,8 @@ {"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": "<any>"}} {"type": "emit_inbound", "label": "session.create.response", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"data": {"id": "ses_f148ca2deffeJcwCnRQtb0YFNX", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790656601394, "updated": 1790656601394}, "title": "spike tool_call", "location": {"directory": "<work>"}}}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb735d32001oBstTIapVlESgH", "created": 1790656601394, "type": "session.created", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "slug": "shiny-pixel", "version": "2.0.18", "projectID": "global", "location": {"directory": "<work>"}, "subpath": "", "title": "spike tool_call", "model": {"id": "big-pickle", "providerID": "opencode"}}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.instructions.entry.put", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "key": "t3-code", "value": "<any>"}}} +{"type": "emit_inbound", "label": "session.instructions.entry.put.response", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "text": "<any>"}}} {"type": "emit_inbound", "label": "session.prompt.response", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0eb735d41001NJee1EvVePJAK5", "sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "time": {"created": 1790656601410}, "type": "user", "payload": {"text": "Use the read tool to read hello.txt, then run the shell command `echo TOOL_OK` with the bash tool, then reply DONE."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb735d42001UMQBpocmd7OqMP", "created": 1790656601410, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb735d41001NJee1EvVePJAK5", "sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "item": {"type": "user", "payload": {"text": "Use the read tool to read hello.txt, then run the shell command `echo TOOL_OK` with the bash tool, then reply DONE."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 1, "version": 1}}}} @@ -33,8 +35,8 @@ {"type": "emit_inbound", "label": "session.usage.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7363a5001EKYF8FhM4SplVu", "created": 1790656603045, "type": "session.usage.updated", "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "cost": 0, "tokens": {"input": 8767, "output": 59, "reasoning": 0, "cache": {"read": 487, "write": 0}}}}}} {"type": "emit_inbound", "label": "session.step.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7366f2001WQBXMBgRtPuQh1", "created": 1790656603890, "type": "session.step.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "assistantMessageID": "msg_0eb7363b20015PvR7k5Er4JlrJ", "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "started": 1790656603085}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 18, "version": 1}}}} {"type": "emit_inbound", "label": "session.text.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7366f7001rQGF9KFTnxrW1P", "created": 1790656603895, "type": "session.text.started", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb7363b20015PvR7k5Er4JlrJ", "ordinal": 0}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 19, "version": 1}}}} -{"type": "emit_inbound", "label": "session.text.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73675f001lQg1eGqHoCo1vk", "created": 1790656603999, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb7363b20015PvR7k5Er4JlrJ", "ordinal": 0, "delta": "Both tools worked:\n\n- `read hello.txt` → `hello from the spike`\n- `echo TOOL_OK` → `TOOL_OK`\n\nDONE"}}}} -{"type": "emit_inbound", "label": "session.text.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73677c001jJzmP1jN5gZUfh", "created": 1790656604028, "type": "session.text.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb7363b20015PvR7k5Er4JlrJ", "ordinal": 0, "text": "Both tools worked:\n\n- `read hello.txt` → `hello from the spike`\n- `echo TOOL_OK` → `TOOL_OK`\n\nDONE"}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 20, "version": 1}}}} +{"type": "emit_inbound", "label": "session.text.delta.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73675f001lQg1eGqHoCo1vk", "created": 1790656603999, "type": "session.text.delta", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb7363b20015PvR7k5Er4JlrJ", "ordinal": 0, "delta": "Both tools worked:\n\n- `read hello.txt` \u2192 `hello from the spike`\n- `echo TOOL_OK` \u2192 `TOOL_OK`\n\nDONE"}}}} +{"type": "emit_inbound", "label": "session.text.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73677c001jJzmP1jN5gZUfh", "created": 1790656604028, "type": "session.text.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb7363b20015PvR7k5Er4JlrJ", "ordinal": 0, "text": "Both tools worked:\n\n- `read hello.txt` \u2192 `hello from the spike`\n- `echo TOOL_OK` \u2192 `TOOL_OK`\n\nDONE"}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 20, "version": 1}}}} {"type": "emit_inbound", "label": "session.step.streamed.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7367830016divwEpkO8f6Eu", "created": 1790656604035, "type": "session.step.streamed", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb7363b20015PvR7k5Er4JlrJ"}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 21, "version": 1}}}} {"type": "emit_inbound", "label": "session.step.ended.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb73678f001CMnYCUExYim7Lu", "created": 1790656604047, "type": "session.step.ended", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "assistantMessageID": "msg_0eb7363b20015PvR7k5Er4JlrJ", "finish": "stop", "rawFinish": "stop", "cost": 0, "tokens": {"input": 45, "output": 33, "reasoning": 0, "cache": {"read": 9311, "write": 0}}, "snapshot": "99482b54df6361365fa0ed7363ccf07edaa7fc93", "files": []}, "durable": {"aggregateID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "seq": 22, "version": 1}}}} {"type": "emit_inbound", "label": "session.usage.updated.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb736795001KWJroUfZyIUpKX", "created": 1790656604053, "type": "session.usage.updated", "data": {"sessionID": "ses_f148ca2deffeJcwCnRQtb0YFNX", "cost": 0, "tokens": {"input": 8812, "output": 92, "reasoning": 0, "cache": {"read": 9798, "write": 0}}}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index ebb65c455a26..bce2ec628186 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -90,6 +90,10 @@ export const OPENCODE2_BACKGROUND_PROMPT = "Use the subagent tool with background enabled to delegate to the general subagent with the prompt: 'Run the shell command `sleep 20` with the bash tool and then reply exactly CHILD_OK.' As soon as it is launched, reply exactly PARENT_OK and end your turn without waiting for it."; export const OPENCODE2_COMPACTION_FIRST_PROMPT = "Remember the codeword PAPAYA. Reply OK."; export const OPENCODE2_COMPACTION_RECALL_PROMPT = "What was the codeword? One word."; +export const OPENCODE2_RESTART_PROMPT = + "Run the shell command `sleep 25 && echo RESUMED` with the bash tool, then reply with its output."; +export const OPENCODE2_RESTART_RECALL_PROMPT = + "What did I last ask you to run? Answer in one short sentence."; export const OPENCODE2_COMMAND_PROMPT = "/hello WORLD"; export const OPENCODE2_SKILL_PROMPT = "Use $greet to say hi in three words."; export const TURN_INTERRUPT_PROMPT = diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts index 9bdca1789515..48100991e71c 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts @@ -1,24 +1,32 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { ProviderInstanceId, type OpenCodeSettings } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; +import * as ProviderMaintenance from "../providerMaintenance.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { OPENCODE_2_RESPONSES, OPENCODE_2_WORKSPACE_RESPONSES, replayOpenCodeServer, } from "../testFixtures/opencodeProbeResponses.ts"; -import { OPENCODE_2_TEXT_GENERATION_UNSUPPORTED, OpenCodeDriver } from "./OpenCodeDriver.ts"; +import { OpenCodeDriver, openCodeUpdateFor } from "./OpenCodeDriver.ts"; const serverStarts: Array<string> = []; const reachedServer = (operation: string) => @@ -67,15 +75,15 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { Effect.gen(function* () { serverStarts.length = 0; const instance = yield* create({}, noHttp); - const title = yield* Effect.flip( + // Text generation goes to the instance's 2.x server, which this test refuses to start. + yield* Effect.flip( instance.textGeneration.generateThreadTitle({ cwd: process.cwd(), message: "hello", modelSelection: { instanceId: instance.instanceId, model: "opencode/big-pickle" }, }), ); - assert.strictEqual(title.detail, OPENCODE_2_TEXT_GENERATION_UNSUPPORTED); - assert.deepStrictEqual(serverStarts, []); + assert.deepStrictEqual(serverStarts, ["start"]); }).pipe(Effect.scoped), ); @@ -122,3 +130,150 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); }); + +// npm links `<prefix>/bin/opencode` into the package that installed it: +// `opencode-ai` for 1.x and `@opencode/cli` (whose bin is `bin/opencode.exe`) for 2.x. +const npmGlobalInstall = (prefix: string, packagePath: ReadonlyArray<string>, bin: string) => { + const target = NodePath.join(prefix, "lib", "node_modules", ...packagePath, "bin", bin); + NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true }); + NodeFS.writeFileSync(target, "#!/bin/sh\n"); + NodeFS.chmodSync(target, 0o755); + const link = NodePath.join(prefix, "bin", "opencode"); + NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); + NodeFS.symlinkSync(target, link); + return link; +}; +const resolveUpdate = (generation: "v1" | "v2", binaryPath: string) => + ProviderMaintenance.resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation), { + binaryPath, + env: { PATH: "" }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("resolving an update must not spawn")), + ), + Effect.provide(NodeServices.layer), + ); + +it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "updates an npm-global OpenCode 2 install as @opencode/cli and a 1.x one as opencode-ai", + () => + Effect.gen(function* () { + const root = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-update-")), + ); + const v2Prefix = NodePath.join(root, "v2"); + const v1Prefix = NodePath.join(root, "v1"); + const v2 = yield* resolveUpdate( + "v2", + npmGlobalInstall(v2Prefix, ["@opencode", "cli"], "opencode.exe"), + ); + assert.deepStrictEqual(v2.update?.args, [ + "install", + "-g", + "--prefix", + v2Prefix, + "--allow-scripts=@opencode/cli", + "@opencode/cli@latest", + ]); + const v1 = yield* resolveUpdate( + "v1", + npmGlobalInstall(v1Prefix, ["opencode-ai"], "opencode"), + ); + assert.strictEqual(v1.update?.args.at(-1), "opencode-ai@latest"); + // A 1.x install is never offered 2.x's package, or the reverse. + assert.isNull( + (yield* resolveUpdate("v2", NodePath.join(v1Prefix, "bin", "opencode"))).update, + ); + assert.isNull( + (yield* resolveUpdate("v1", NodePath.join(v2Prefix, "bin", "opencode"))).update, + ); + }), +); + +// The update the driver offers follows the version the binary reports now. +const versionOutput: { current: string | undefined } = { current: undefined }; +const versionProbes: Array<string> = []; +const changingRuntime = { + runOpenCodeCommand: ({ binaryPath }: { readonly binaryPath: string }) => + Effect.sync(() => versionProbes.push(binaryPath)).pipe( + Effect.andThen( + versionOutput.current === undefined + ? Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "version", + detail: "no version", + }), + ) + : Effect.succeed({ stdout: versionOutput.current, stderr: "", code: 0 }), + ), + ), + startOpenCodeServerProcess: () => reachedServer("start"), + connectToOpenCodeServer: () => reachedServer("connect"), +} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; +const updateLayer = Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-driver-update-" }), + IdAllocator.layer, + ServerSettings.layerTest(), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, changingRuntime), +).pipe(Layer.provideMerge(NodeServices.layer)); + +it.layer(updateLayer)("OpenCodeDriver updates", (it) => { + it.effect("never runs the binary for a disabled instance's update check", () => + Effect.gen(function* () { + versionProbes.length = 0; + versionOutput.current = "opencode v2.0.18\n"; + const instance = yield* OpenCodeDriver.create({ + instanceId: ProviderInstanceId.make("opencode-disabled"), + displayName: undefined, + environment: [], + enabled: false, + config: { ...OpenCodeDriver.defaultConfig(), binaryPath: "opencode" }, + }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp)); + const maintenance = yield* instance.snapshot.resolveMaintenance(); + yield* instance.snapshot.resolveMaintenance({ fresh: true }); + assert.isNull(maintenance.update); + assert.deepStrictEqual(versionProbes, []); + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "offers no package update for an unknown version and follows a changed one on a fresh read", + () => + Effect.gen(function* () { + const root = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-driver-update-")), + ); + const binaryPath = npmGlobalInstall(NodePath.join(root, "v1"), ["opencode-ai"], "opencode"); + const instance = yield* OpenCodeDriver.create({ + instanceId: ProviderInstanceId.make("opencode-update"), + displayName: undefined, + environment: [], + enabled: true, + config: { ...OpenCodeDriver.defaultConfig(), binaryPath }, + }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp)); + + // The version could not be read: which package owns the binary is unknown. + versionOutput.current = undefined; + const unknown = yield* instance.snapshot.resolveMaintenance({ fresh: true }); + assert.isNull(unknown.update); + assert.isNull(unknown.packageName); + + versionOutput.current = "1.18.32\n"; + const v1 = yield* instance.snapshot.resolveMaintenance({ fresh: true }); + assert.strictEqual(v1.packageName, "opencode-ai"); + + // The same path now reports 2.x (reinstalled in place): a fresh read re-probes + // and never offers 1.x's package for it. + versionOutput.current = "opencode v2.0.18\n"; + const v2 = yield* instance.snapshot.resolveMaintenance({ fresh: true }); + assert.strictEqual(v2.packageName, "@opencode/cli"); + assert.isNull(v2.update); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 4d8b524956ac..a16516c40a27 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -12,7 +12,7 @@ * * @module provider/Drivers/OpenCodeDriver */ -import { OpenCodeSettings, ProviderDriverKind, TextGenerationError } from "@t3tools/contracts"; +import { OpenCodeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -24,6 +24,7 @@ import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; +import * as OpenCode2TextGeneration from "../../textGeneration/OpenCode2TextGeneration.ts"; import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; @@ -64,6 +65,7 @@ import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment import { enrichProviderSnapshotWithVersionAdvisory, makeCachedProviderMaintenanceResolution, + makeManualOnlyProviderMaintenanceCapabilities, makePackageManagedProviderMaintenanceResolver, normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, @@ -85,17 +87,20 @@ function isOpenCodeNativeCommandPath(commandPath: string): boolean { ); } -const UPDATE = makePackageManagedProviderMaintenanceResolver({ - provider: DRIVER_KIND, - npmPackageName: "opencode-ai", - nativeUpdate: { - args: ["upgrade"], - isCommandPath: isOpenCodeNativeCommandPath, - }, -}); - -export const OPENCODE_2_TEXT_GENERATION_UNSUPPORTED = - "T3 Code cannot generate text with OpenCode 2 yet."; +/** + * OpenCode 1.x ships as `opencode-ai` and 2.x as `@opencode/cli`. An install is + * only ever updated within its own package: T3 never moves a 1.x install onto + * 2.x or back, since 2.x converts the shared database in place. + */ +export const openCodeUpdateFor = (generation: ProbedOpenCode["generation"]) => + makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: generation === "v2" ? "@opencode/cli" : "opencode-ai", + nativeUpdate: { + args: ["upgrade"], + isCommandPath: isOpenCodeNativeCommandPath, + }, + }); type OpenCodeRuntimeProbe = Effect.Success< ReturnType<typeof makeOpenCodeRuntimeProbe<OpenCodeRuntime.OpenCodeRuntimeError>> @@ -136,35 +141,32 @@ function selectOpenCodeRuntimeAdapter(input: { }; } -/** Text generation starts or connects to a 1.x server per call, so a 2.x is refused first. */ +/** Text generation runs on the server the instance's probe detected, each in its own protocol. */ function selectOpenCodeRuntimeTextGeneration( probe: OpenCodeRuntimeProbe, v1: TextGeneration["Service"], + v2: TextGeneration["Service"], ): TextGeneration["Service"] { - const refuse = (operation: string) => - Effect.fail( - new TextGenerationError({ operation, detail: OPENCODE_2_TEXT_GENERATION_UNSUPPORTED }), - ); return { generateCommitMessage: (input) => byOpenCodeRuntime(probe.get, { v1: v1.generateCommitMessage(input), - v2: refuse("generateCommitMessage"), + v2: v2.generateCommitMessage(input), }), generatePrContent: (input) => byOpenCodeRuntime(probe.get, { v1: v1.generatePrContent(input), - v2: refuse("generatePrContent"), + v2: v2.generatePrContent(input), }), generateBranchName: (input) => byOpenCodeRuntime(probe.get, { v1: v1.generateBranchName(input), - v2: refuse("generateBranchName"), + v2: v2.generateBranchName(input), }), generateThreadTitle: (input) => byOpenCodeRuntime(probe.get, { v1: v1.generateThreadTitle(input), - v2: refuse("generateThreadTitle"), + v2: v2.generateThreadTitle(input), }), }; } @@ -211,23 +213,50 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; - const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( - resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - ), - ); - const runtimeProbe = yield* makeOpenCodeRuntimeProbe( probeOpenCodeRuntime(effectiveConfig, processEnv).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), ), ); + // Updates follow the installed package, which the version probe names. An + // unknown version offers no package update, since a guess could move a + // 2.x install onto 1.x's package or the reverse. A disabled instance never + // runs its binary, so it has no version and offers no update. + const noUpdate = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }); + const maintenanceFor = (probed: typeof runtimeProbe.get) => + probed.pipe( + Effect.map((result) => result.generation), + Effect.option, + Effect.flatMap((generation) => + Option.isNone(generation) + ? Effect.succeed(noUpdate) + : resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation.value), { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + ); + const cachedMaintenance = yield* makeCachedProviderMaintenanceResolution( + maintenanceFor(runtimeProbe.get), + ); + // A fresh read (an update about to run, or a manual refresh) re-probes, so + // a binary replaced by the other major version gets its own package. + const resolveMaintenance = (options?: { readonly fresh?: boolean }) => + !effectiveConfig.enabled + ? Effect.succeed(noUpdate) + : options?.fresh === true + ? runtimeProbe.refresh.pipe( + Effect.ignore, + Effect.andThen(cachedMaintenance({ fresh: true })), + ) + : cachedMaintenance(); const openCodeV1Adapter = yield* OpenCodeAdapterV2.OpenCodeAdapterV2Driver.create({ instanceId, displayName, @@ -333,6 +362,9 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> yield* makeOpenCodeTextGeneration(effectiveConfig).pipe( Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), ), + yield* OpenCode2TextGeneration.make().pipe( + Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), + ), ); const checkProvider = Effect.all( diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts new file mode 100644 index 000000000000..8c354044061d --- /dev/null +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts @@ -0,0 +1,371 @@ +/** + * Thread title generation on OpenCode 2's free tier, recorded live against + * 2.0.18 with `opencode/big-pickle` on 2026-09-30: an ask-all temporary + * session, one prompt, the reply read from the event stream, the session + * removed. (A deny-all session is refused on the free tier: the spike's + * `text_generation` recording.) + */ +import type { ProviderReplayEntry } from "@t3tools/contracts"; + +export const OPENCODE2_TITLE_GENERATION: ReadonlyArray<ProviderReplayEntry> = [ + { type: "expect_outbound", label: "event.subscribe", frame: { type: "event.subscribe" } }, + { + type: "emit_inbound", + label: "server.connected", + frame: { + type: "sdk.event", + event: { id: "evt_0f137a1d2001lU10ZRa5EiLHAw", type: "server.connected", data: {} }, + }, + }, + { + type: "expect_outbound", + label: "session.create", + frame: { + type: "session.create", + input: { + title: "T3 Code generateThreadTitle", + location: { directory: "<any>" }, + model: { providerID: "opencode", id: "big-pickle" }, + permissions: [{ action: "*", resource: "*", effect: "ask" }], + }, + }, + }, + { + type: "emit_inbound", + label: "session.create.response", + frame: { + type: "sdk.response", + operation: "session.create", + data: { + data: { + id: "ses_f0ec85cf6ffebfalpvV2E9nocu", + projectID: "global", + model: { id: "big-pickle", providerID: "opencode", variant: "default" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1790753350431, updated: 1790753350431 }, + title: "T3 Code generateThreadTitle", + permissions: [{ action: "*", resource: "*", effect: "ask" }], + location: { directory: "<work>" }, + }, + }, + }, + }, + { + type: "emit_inbound", + label: "session.created", + frame: { + type: "sdk.event", + event: { + id: "evt_0f137a31f001y6hIo9JFrLXhBa", + created: 1790753350431, + type: "session.created", + location: { directory: "<work>" }, + data: { + sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu", + slug: "quiet-lagoon", + version: "2.0.18", + projectID: "global", + location: { directory: "<work>" }, + subpath: "", + title: "T3 Code generateThreadTitle", + permissions: [{ action: "*", resource: "*", effect: "ask" }], + model: { id: "big-pickle", providerID: "opencode" }, + }, + durable: { aggregateID: "ses_f0ec85cf6ffebfalpvV2E9nocu", seq: 0, version: 1 }, + }, + }, + }, + { + type: "emit_inbound", + label: "project.updated", + frame: { + type: "sdk.event", + event: { + id: "evt_0f137a32a001SNTdr1fGy7x8KA", + created: 1790753350442, + type: "project.updated", + data: { + id: "global", + canonical: "<work>", + vcs: "git", + time: { created: 1790744321645, updated: 1790744321645, active: 1790753350441 }, + sandboxes: [], + }, + }, + }, + }, + { + type: "expect_outbound", + label: "session.prompt", + frame: { + type: "session.prompt", + input: { sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu", text: "<any>" }, + }, + }, + { + type: "emit_inbound", + label: "session.prompt.response", + frame: { + type: "sdk.response", + operation: "session.prompt", + data: { + data: { + id: "msg_0f137a333001NQCSi7im6QbeRu", + sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu", + time: { created: 1790753350454 }, + type: "user", + payload: { + text: 'You write concise thread titles for coding conversations. Return only a JSON object with key "title". Title: at most 5 words, no quotes. User message: "fix the login redirect loop after oauth"', + }, + delivery: "steer", + }, + }, + }, + }, + { + type: "emit_inbound", + label: "session.inbox.enqueued", + frame: { + type: "sdk.event", + event: { + id: "evt_0f137a336001aSMpzX88Wc2ggd", + created: 1790753350454, + type: "session.inbox.enqueued", + location: { directory: "<work>" }, + data: { + inboxID: "msg_0f137a333001NQCSi7im6QbeRu", + sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu", + item: { + type: "user", + payload: { + text: 'You write concise thread titles for coding conversations. Return only a JSON object with key "title". Title: at most 5 words, no quotes. User message: "fix the login redirect loop after oauth"', + }, + delivery: "steer", + }, + }, + durable: { aggregateID: "ses_f0ec85cf6ffebfalpvV2E9nocu", seq: 1, version: 1 }, + }, + }, + }, + { + type: "emit_inbound", + label: "session.execution.started", + frame: { + type: "sdk.event", + event: { + id: "evt_0f137a33b001LaQlIPhw6OXE4K", + created: 1790753350459, + type: "session.execution.started", + data: { sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu" }, + durable: { aggregateID: "ses_f0ec85cf6ffebfalpvV2E9nocu", seq: 2, version: 1 }, + }, + }, + }, + { + type: "emit_inbound", + label: "session.instructions.updated", + frame: { + type: "sdk.event", + event: { + id: "evt_0f137a355001zRM26oGzBOQK1I", + created: 1790753350485, + metadata: { instructions: { initial: true } }, + type: "session.instructions.updated", + location: { directory: "<work>" }, + data: { + sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu", + delta: { + "core/environment": "00e638e6e54c2bb7786ab0bade9b0f8fcb6401b64ef49fc25025dc97dc2d61ac", + "core/date": "5a13dc24ad554b3e0bf9583fa4a5c52e130b32720d0a9562fd0e9b0ab14210a9", + "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", + "core/skill-guidance": + "b2aff856e4c0afa54b6d56ecdc438d66a6333b36a3b62677bb1a96da62079118", + }, + }, + durable: { aggregateID: "ses_f0ec85cf6ffebfalpvV2E9nocu", seq: 3, version: 2 }, + }, + }, + }, + { + type: "emit_inbound", + label: "session.inbox.delivered", + frame: { + type: "sdk.event", + event: { + id: "evt_0f137a358001Nb4SR6NyY00Zo5", + created: 1790753350489, + type: "session.inbox.delivered", + location: { directory: "<work>" }, + data: { + sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu", + inboxID: "msg_0f137a333001NQCSi7im6QbeRu", + }, + durable: { aggregateID: "ses_f0ec85cf6ffebfalpvV2E9nocu", seq: 4, version: 1 }, + }, + }, + }, + { + type: "emit_inbound", + label: "session.step.started", + frame: { + type: "sdk.event", + event: { + id: "evt_0f137a9a1001mdhCnni47bUQ1S", + created: 1790753352097, + type: "session.step.started", + location: { directory: "<work>" }, + data: { + sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu", + agent: "build", + model: { id: "big-pickle", providerID: "opencode", variant: "default" }, + assistantMessageID: "msg_0f137a376001CNO3tIF0OHqFsx", + snapshot: "5ecf8511987b833d27b391b832f802775c5ca199", + started: 1790753350567, + }, + durable: { aggregateID: "ses_f0ec85cf6ffebfalpvV2E9nocu", seq: 5, version: 1 }, + }, + }, + }, + { + type: "emit_inbound", + label: "session.text.started", + frame: { + type: "sdk.event", + event: { + id: "evt_0f137a9a5001wyccz82SmtjxpF", + created: 1790753352101, + type: "session.text.started", + location: { directory: "<work>" }, + data: { + sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu", + assistantMessageID: "msg_0f137a376001CNO3tIF0OHqFsx", + ordinal: 0, + }, + durable: { aggregateID: "ses_f0ec85cf6ffebfalpvV2E9nocu", seq: 6, version: 1 }, + }, + }, + }, + { + type: "emit_inbound", + label: "session.text.delta", + frame: { + type: "sdk.event", + event: { + id: "evt_0f137a9b5001pgNezPn61VhcVj", + created: 1790753352117, + type: "session.text.delta", + location: { directory: "<work>" }, + data: { + sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu", + assistantMessageID: "msg_0f137a376001CNO3tIF0OHqFsx", + ordinal: 0, + delta: '{"title": "Fix OAuth Login Redirect Loop"}', + }, + }, + }, + }, + { + type: "emit_inbound", + label: "session.text.ended", + frame: { + type: "sdk.event", + event: { + id: "evt_0f137a9b6001W0DZEOeIf3qavZ", + created: 1790753352118, + type: "session.text.ended", + location: { directory: "<work>" }, + data: { + sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu", + assistantMessageID: "msg_0f137a376001CNO3tIF0OHqFsx", + ordinal: 0, + text: '{"title": "Fix OAuth Login Redirect Loop"}', + }, + durable: { aggregateID: "ses_f0ec85cf6ffebfalpvV2E9nocu", seq: 7, version: 1 }, + }, + }, + }, + { + type: "emit_inbound", + label: "session.step.streamed", + frame: { + type: "sdk.event", + event: { + id: "evt_0f137a9ba0012p1Dt72Qx5gAbw", + created: 1790753352122, + type: "session.step.streamed", + location: { directory: "<work>" }, + data: { + sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu", + assistantMessageID: "msg_0f137a376001CNO3tIF0OHqFsx", + }, + durable: { aggregateID: "ses_f0ec85cf6ffebfalpvV2E9nocu", seq: 8, version: 1 }, + }, + }, + }, + { + type: "emit_inbound", + label: "session.step.ended", + frame: { + type: "sdk.event", + event: { + id: "evt_0f137a9c8001CVVUaPgaXOAnJ3", + created: 1790753352136, + type: "session.step.ended", + location: { directory: "<work>" }, + data: { + sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu", + assistantMessageID: "msg_0f137a376001CNO3tIF0OHqFsx", + finish: "stop", + rawFinish: "stop", + cost: 0, + tokens: { input: 8927, output: 11, reasoning: 0, cache: { read: 487, write: 0 } }, + snapshot: "5ecf8511987b833d27b391b832f802775c5ca199", + files: [], + }, + durable: { aggregateID: "ses_f0ec85cf6ffebfalpvV2E9nocu", seq: 9, version: 1 }, + }, + }, + }, + { + type: "emit_inbound", + label: "session.usage.updated", + frame: { + type: "sdk.event", + event: { + id: "evt_0f137a9cd001vwSgA3M8W9SR9P", + created: 1790753352141, + type: "session.usage.updated", + data: { + sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu", + cost: 0, + tokens: { input: 8927, output: 11, reasoning: 0, cache: { read: 487, write: 0 } }, + }, + }, + }, + }, + { + type: "emit_inbound", + label: "session.execution.succeeded", + frame: { + type: "sdk.event", + event: { + id: "evt_0f137a9ce001qLIAQz6psEXDM3", + created: 1790753352142, + type: "session.execution.succeeded", + data: { sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu" }, + durable: { aggregateID: "ses_f0ec85cf6ffebfalpvV2E9nocu", seq: 10, version: 1 }, + }, + }, + }, + { + type: "expect_outbound", + label: "session.remove", + frame: { type: "session.remove", input: { sessionID: "ses_f0ec85cf6ffebfalpvV2E9nocu" } }, + }, + { + type: "emit_inbound", + label: "session.remove.response", + frame: { type: "sdk.response", operation: "session.remove", data: null }, + }, +]; diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts new file mode 100644 index 000000000000..eaed9ea6abd6 --- /dev/null +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts @@ -0,0 +1,266 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; +import * as OpenCode2AdapterV2Testkit from "../orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts"; +import * as OpenCode2Server from "../provider/opencode2/OpenCode2Server.ts"; +import * as OpenCode2TextGeneration from "./OpenCode2TextGeneration.ts"; +import { OPENCODE2_TITLE_GENERATION } from "./OpenCode2TextGeneration.fixture.ts"; + +const layer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-opencode2-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +it.layer(layer)("OpenCode2TextGeneration", (it) => { + it.effect("generates a title in a temporary session on the free tier and removes it", () => + Effect.gen(function* () { + const server = yield* OpenCode2AdapterV2Testkit.replayServer({ + provider: "opencode", + protocol: OpenCode2AdapterV2Testkit.OPENCODE2_HTTP_PROTOCOL, + version: "2.0.18", + scenario: "opencode2_title_generation", + entries: [...OPENCODE2_TITLE_GENERATION, { type: "runtime_exit", status: "success" }], + }); + const textGeneration = yield* OpenCode2TextGeneration.make().pipe( + Effect.provideService(OpenCode2Server.OpenCode2Server, server), + ); + const title = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "fix the login redirect loop after oauth", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "opencode/big-pickle", + }, + }); + assert.equal(title.title, "Fix OAuth Login Redirect Loop"); + }).pipe(Effect.scoped), + ); + + it.effect("fails at once when the event stream drops before the reply", () => + Effect.gen(function* () { + const [subscribe, connected, create, created] = OPENCODE2_TITLE_GENERATION; + const sessionId = "ses_f0ec85cf6ffebfalpvV2E9nocu"; + const server = yield* OpenCode2AdapterV2Testkit.replayServer({ + provider: "opencode", + protocol: OpenCode2AdapterV2Testkit.OPENCODE2_HTTP_PROTOCOL, + version: "2.0.18", + scenario: "opencode2_title_generation_stream_lost", + entries: [ + subscribe!, + connected!, + create!, + created!, + { + type: "expect_outbound", + frame: { type: "session.prompt", input: { sessionID: sessionId, text: "<any>" } }, + }, + { + type: "emit_inbound", + frame: { + type: "sdk.response", + operation: "session.prompt", + data: { + data: { + id: "msg_1", + sessionID: sessionId, + time: { created: 1 }, + type: "user", + payload: { text: "t" }, + delivery: "steer", + }, + }, + }, + }, + // The server goes away before the reply; the temporary session is still removed. + { type: "runtime_exit", status: "success" }, + { + type: "expect_outbound", + frame: { type: "session.remove", input: { sessionID: sessionId } }, + }, + { + type: "emit_inbound", + frame: { type: "sdk.response", operation: "session.remove", data: null }, + }, + ], + }); + const textGeneration = yield* OpenCode2TextGeneration.make().pipe( + Effect.provideService(OpenCode2Server.OpenCode2Server, server), + ); + const failure = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "fix the login redirect loop after oauth", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "opencode/big-pickle", + }, + }) + .pipe(Effect.flip, Effect.timeout("10 seconds")); + assert.equal(failure._tag, "TextGenerationError"); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a provider's own error message out of the caller-visible detail", () => + Effect.gen(function* () { + const [subscribe, connected, create, created] = OPENCODE2_TITLE_GENERATION; + const sessionId = "ses_f0ec85cf6ffebfalpvV2E9nocu"; + // The spike's `text_generation` recording: a deny-all session is refused this way. + const refusal = { + type: "provider.auth", + message: + "Error from provider (Console): OpenCode's free tier can only be used from within OpenCode", + status: 403, + }; + const server = yield* OpenCode2AdapterV2Testkit.replayServer({ + provider: "opencode", + protocol: OpenCode2AdapterV2Testkit.OPENCODE2_HTTP_PROTOCOL, + version: "2.0.18", + scenario: "opencode2_title_generation_refused", + entries: [ + subscribe!, + connected!, + create!, + created!, + { + type: "expect_outbound", + frame: { type: "session.prompt", input: { sessionID: sessionId, text: "<any>" } }, + }, + { + type: "emit_inbound", + frame: { + type: "sdk.response", + operation: "session.prompt", + data: { + data: { + id: "msg_1", + sessionID: sessionId, + time: { created: 1 }, + type: "user", + payload: { text: "t" }, + delivery: "steer", + }, + }, + }, + }, + { + type: "emit_inbound", + frame: { + type: "sdk.event", + event: { + id: "evt_0eb7f9ae0001VCNDHVLnqTfoPG", + created: 1790657403616, + type: "session.execution.failed", + data: { sessionID: sessionId, error: refusal }, + durable: { aggregateID: sessionId, seq: 7, version: 1 }, + }, + }, + }, + { + type: "expect_outbound", + frame: { type: "session.remove", input: { sessionID: sessionId } }, + }, + { + type: "emit_inbound", + frame: { type: "sdk.response", operation: "session.remove", data: null }, + }, + { type: "runtime_exit", status: "success" }, + ], + }); + const textGeneration = yield* OpenCode2TextGeneration.make().pipe( + Effect.provideService(OpenCode2Server.OpenCode2Server, server), + ); + const failure = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "fix the login redirect loop after oauth", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "opencode/big-pickle", + }, + }) + .pipe(Effect.flip); + assert.equal(failure.detail, "OpenCode could not generate the text."); + assert.deepEqual(failure.cause, refusal); + }).pipe(Effect.scoped), + ); + + it.effect("ends at once on an execution end this build cannot decode", () => + Effect.gen(function* () { + const [subscribe, connected, create, created] = OPENCODE2_TITLE_GENERATION; + const sessionId = "ses_f0ec85cf6ffebfalpvV2E9nocu"; + const server = yield* OpenCode2AdapterV2Testkit.replayServer({ + provider: "opencode", + protocol: OpenCode2AdapterV2Testkit.OPENCODE2_HTTP_PROTOCOL, + version: "2.0.18", + scenario: "opencode2_title_generation_unreadable_end", + entries: [ + subscribe!, + connected!, + create!, + created!, + { + type: "expect_outbound", + frame: { type: "session.prompt", input: { sessionID: sessionId, text: "<any>" } }, + }, + { + type: "emit_inbound", + frame: { + type: "sdk.response", + operation: "session.prompt", + data: { + data: { + id: "msg_1", + sessionID: sessionId, + time: { created: 1 }, + type: "user", + payload: { text: "t" }, + delivery: "steer", + }, + }, + }, + }, + // A reason added after 2.0.18: the full schema rejects the frame. + { + type: "emit_inbound", + frame: { + type: "sdk.event", + event: { + id: "evt_0eb7f9ae0001VCNDHVLnqTfoPG", + created: 1790657403616, + type: "session.execution.interrupted", + data: { sessionID: sessionId, reason: "budget" }, + durable: { aggregateID: sessionId, seq: 7, version: 1 }, + }, + }, + }, + { + type: "expect_outbound", + frame: { type: "session.remove", input: { sessionID: sessionId } }, + }, + { + type: "emit_inbound", + frame: { type: "sdk.response", operation: "session.remove", data: null }, + }, + { type: "runtime_exit", status: "success" }, + ], + }); + const textGeneration = yield* OpenCode2TextGeneration.make().pipe( + Effect.provideService(OpenCode2Server.OpenCode2Server, server), + ); + const failure = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "fix the login redirect loop after oauth", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "opencode/big-pickle", + }, + }) + .pipe(Effect.flip, Effect.timeout("10 seconds")); + assert.equal(failure._tag, "TextGenerationError"); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts new file mode 100644 index 000000000000..a30619e2ad64 --- /dev/null +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts @@ -0,0 +1,221 @@ +/** + * Text generation on an OpenCode 2 server. Both of its `generate` endpoints + * answer 403 on OpenCode's free tier ("free tier can only be used from within + * OpenCode"), so each request runs as a prompt in a temporary session, as 1.x + * does, and the session is removed afterwards. + * + * @module textGeneration/OpenCode2TextGeneration + */ +import { AbsolutePath, Location, Model, Provider, Session } from "@opencode/client/effect"; +import { TextGenerationError } from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../config.ts"; +import { resolveAttachmentPath } from "../attachmentStore.ts"; +import type { OpenCode2Connection } from "../provider/opencode2/OpenCode2Server.ts"; +import * as OpenCode2Server from "../provider/opencode2/OpenCode2Server.ts"; +import { parseOpenCodeModelSlug } from "../provider/opencodeRuntime.ts"; +import { makeOpenCodeOperations, type OpenCodeJsonRunner } from "./OpenCodeTextGeneration.ts"; + +const isTextGenerationError = Schema.is(TextGenerationError); + +/** A generation that takes this long is stuck; its session is stopped and removed. */ +const GENERATION_TIMEOUT = "3 minutes"; + +/** + * Nothing in a text generation needs a tool, so every tool asks and T3 is not + * there to answer. Denying `shell` or `read` outright gets the whole session + * refused on OpenCode's free models, and an ask that is never answered would + * hang, so asks are rejected as they arrive. + */ +const GENERATION_PERMISSIONS = [{ action: "*", resource: "*", effect: "ask" }] as const; + +/** How a generation ended. A failure's cause is the provider's own error, kept out of `detail`. */ +type Outcome = + | { readonly _tag: "text"; readonly text: string } + | { readonly _tag: "failed"; readonly detail: string; readonly cause?: unknown }; + +const runOnServer = ( + connection: OpenCode2Connection, + input: Parameters<OpenCodeJsonRunner>[0], + attachmentsDir: string, +) => + Effect.gen(function* () { + const { client } = connection; + const parsed = parseOpenCodeModelSlug(input.modelSelection.model); + if (parsed === null) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode model selection must use the 'provider/model' format.", + }); + } + const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const outcome = yield* Deferred.make<Outcome>(); + let sessionId: string | undefined; + const texts = new Map<string, string>(); + // Subscribed first: the stream is volatile, and the reply arrives on it. + const events = yield* connection.events; + yield* events.pipe( + Stream.runForEach((event) => { + // An execution end a newer server sent in a shape this build cannot + // read still ends the generation, as it ends a turn in the adapter. + if (event.type === "unreadable.execution.ended") { + if (event.sessionID !== sessionId) return Effect.void; + return Deferred.succeed( + outcome, + event.executionType === "session.execution.succeeded" + ? { _tag: "text", text: [...texts.values()].join("\n").trim() } + : { + _tag: "failed", + detail: "OpenCode ended the generation in a way this version cannot read.", + cause: event, + }, + ); + } + if ( + sessionId === undefined || + !("data" in event) || + !("sessionID" in event.data) || + event.data.sessionID !== sessionId + ) { + return Effect.void; + } + switch (event.type) { + case "session.text.ended": + texts.set(`${event.data.assistantMessageID}:${event.data.ordinal}`, event.data.text); + return Effect.void; + case "permission.asked": + return client.permission + .reply({ + sessionID: event.data.sessionID, + requestID: event.data.id, + decision: "reject", + message: "Tools are not available while generating text.", + }) + .pipe(Effect.ignore({ log: true })); + case "session.execution.succeeded": + return Deferred.succeed(outcome, { + _tag: "text", + text: [...texts.values()].join("\n").trim(), + }); + case "session.execution.failed": + return Deferred.succeed(outcome, { + _tag: "failed", + detail: "OpenCode could not generate the text.", + cause: event.data.error, + }); + case "session.execution.interrupted": + return Deferred.succeed(outcome, { + _tag: "failed", + detail: "OpenCode stopped the generation.", + }); + default: + return Effect.void; + } + }), + // The reply only arrives on this stream, so a lost stream ends the wait. + Effect.exit, + Effect.flatMap((exit) => + Deferred.succeed(outcome, { + _tag: "failed", + detail: "The OpenCode event stream was lost.", + cause: exit, + }), + ), + Effect.forkScoped, + ); + const session = yield* client.session.create({ + title: `T3 Code ${input.operation}`, + location: Location.PublicRef.make({ directory: AbsolutePath.make(input.cwd) }), + model: Model.Ref.make({ + providerID: Provider.ID.make(parsed.providerID), + id: Model.ID.make(parsed.modelID), + ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), + }), + permissions: GENERATION_PERMISSIONS, + }); + sessionId = session.id; + yield* Effect.addFinalizer(() => + client.session + .remove({ sessionID: session.id }) + .pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })), + ); + const images = (input.attachments ?? []).flatMap((attachment) => { + if (attachment.type !== "image") return []; + const path = resolveAttachmentPath({ attachmentsDir, attachment }); + return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }]; + }); + yield* client.session.prompt({ + sessionID: session.id, + text: input.prompt, + ...(images.length === 0 ? {} : { files: images }), + }); + const result = yield* Deferred.await(outcome).pipe( + Effect.timeoutOrElse({ + duration: GENERATION_TIMEOUT, + orElse: () => + client.session.interrupt({ sessionID: Session.ID.make(session.id) }).pipe( + Effect.ignore, + Effect.as<Outcome>({ + _tag: "failed", + detail: "OpenCode did not finish generating in time.", + }), + ), + }), + ); + if (result._tag === "failed") { + return yield* new TextGenerationError({ + operation: input.operation, + detail: result.detail, + ...(result.cause === undefined ? {} : { cause: result.cause }), + }); + } + if (result.text.length === 0) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned empty output.", + }); + } + return result.text; + }).pipe(Effect.scoped); + +/** Text generation for an instance whose server is OpenCode 2. */ +export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () { + const server = yield* OpenCode2Server.OpenCode2Server; + const { attachmentsDir } = yield* ServerConfig.ServerConfig; + const run: OpenCodeJsonRunner = (input) => { + // Each operation has its own output schema, as in 1.x. + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); + return server + .withConnection((connection) => runOnServer(connection, input, attachmentsDir)) + .pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation: input.operation, + detail: "OpenCode text generation failed.", + cause, + }), + ), + Effect.flatMap((raw) => + decodeOutput(extractJsonObject(raw)).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned invalid structured output.", + cause, + }), + ), + ), + ), + ); + }; + return makeOpenCodeOperations(run); +}); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 3aa347eca917..ba97ec3006fe 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -359,6 +359,23 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ); }); + return makeOpenCodeOperations(runOpenCodeJson); +}); + +/** Runs one prompt and decodes its reply as `outputSchemaJson`, for either OpenCode runtime. */ +export type OpenCodeJsonRunner = <S extends Schema.Top>(input: { + readonly operation: OpenCodeTextGenerationOperation; + readonly cwd: string; + readonly prompt: string; + readonly outputSchemaJson: S; + readonly modelSelection: ModelSelection; + readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; +}) => Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]>; + +/** The four text generation operations over an OpenCode prompt runner. */ +export function makeOpenCodeOperations( + runOpenCodeJson: OpenCodeJsonRunner, +): TextGeneration.TextGeneration["Service"] { const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = Effect.fn("OpenCodeTextGeneration.generateCommitMessage")(function* (input) { const { prompt, outputSchema } = buildCommitMessagePrompt({ @@ -460,4 +477,4 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" generateBranchName, generateThreadTitle, } satisfies TextGeneration.TextGeneration["Service"]; -}); +} diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 23de99798956..9ecee2a0439c 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -5,6 +5,23 @@ enable it in **Settings > Providers**. See [provider setup](./install.md#provide T3 Code requires OpenCode 1.14.19 or newer, including when you connect an existing OpenCode server. +## OpenCode 2 + +T3 Code supports OpenCode 2.0.18 and newer. It detects the version on its own, so +the same provider settings work for OpenCode 1.x and 2.x. OpenCode 1.x shows +**Limited support** in its provider settings. + +OpenCode 2 is a separate package, `@opencode/cli`. To move from 1.x, install it +yourself, for example `npm install -g @opencode/cli`. Then refresh provider status. +T3 Code's update button updates whichever package you have installed. It never +switches a 1.x install to 2.x. + +OpenCode 2 converts the shared OpenCode database to its own format the first time it +runs. Don't run OpenCode 1.x and 2.x side by side on the same machine. Threads you +started on 1.x continue on 2.x. + +Plan mode uses OpenCode's `plan` agent. + ## Local or external server Leave **Server URL** empty to let T3 Code start OpenCode locally. A password in diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 486fdbc9f49d..c36cf244f9bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -537,6 +537,9 @@ importers: '@opencode/protocol': specifier: 2.0.18 version: 2.0.18 + '@opencode/schema': + specifier: 2.0.18 + version: 2.0.18 diff: specifier: 8.0.3 version: 8.0.3 From 2676d085edada865f87f6ef7b52672a708dba469 Mon Sep 17 00:00:00 2001 From: Mike Olson <mwolson@member.fsf.org> Date: Thu, 1 Oct 2026 03:57:57 -0400 Subject: [PATCH 31/34] fix(opencode): Let OpenCode 2 background wake-ups pass continuation admission When a background subagent finishes, OpenCode 2 runs the parent's reply on its own and T3 holds that output for a continuation turn. A provider continuation is only admitted while the provider shows it still holds undelivered output for the thread, so it cannot start on a session or selection that never produced that output. The OpenCode 2 adapter never reported the output it holds, so every background wake-up was refused and the reply never reached the thread. The adapter now reports held output for a thread while it holds a wake for that thread's session. Delivering the wake, a Stop or a reconnect that drops it ends that report with it. A continuation turn replays output that already ran and sends no model selection, so its start is no longer taken as acknowledging one. --- .../Adapters/OpenCode2AdapterV2.test.ts | 111 +++++++ .../Adapters/OpenCode2AdapterV2.ts | 11 + ...2RollbackWakeAdmission.integration.test.ts | 272 ++++++++++++++++++ 3 files changed, 394 insertions(+) create mode 100644 apps/server/src/orchestration-v2/testkit/OpenCode2RollbackWakeAdmission.integration.test.ts diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index b7b1b800392d..d05fa04a4afb 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -2310,6 +2310,117 @@ describe("OpenCode2 adapter", () => { assert.lengthOf(offers, 1); }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)), ); + // B01a admits a provider continuation only while the provider still holds + // undelivered output for its thread: here, a wake held for its turn. + for (const leaves of ["delivered", "stopped", "dropped by a reconnect"] as const) { + it.effect(`owns a held wake's output until it is ${leaves}`, () => + Effect.gen(function* () { + const offers: Array<ProviderContinuationRequest> = []; + const ownedAtOffer: Array<boolean> = []; + let owned: Effect.Effect<boolean> = Effect.succeed(false); + const { runtime, thread } = yield* resumed([ + ...backgroundLaunch(CHILD), + event("session.execution.succeeded", { sessionID: SESSION }), + event("session.execution.succeeded", { sessionID: CHILD }), + event("session.inbox.enqueued", { + inboxID: "msg_report", + sessionID: SESSION, + item: { + type: "synthetic", + payload: { + text: `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`, + description: "Sleep", + metadata: { + source: "subagent", + childID: CHILD, + agent: "General", + state: "completed", + }, + }, + delivery: "steer", + }, + }), + // OpenCode starts the follow-up on its own; T3 holds it for its turn. + event("session.execution.started", { sessionID: SESSION }), + event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_report" }), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_followup", + ordinal: 0, + text: "CHILD_OK", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ...(leaves === "dropped by a reconnect" + ? [ + { type: "runtime_exit", status: "success" } as const, + out("event.subscribe"), + out("session.active"), + replyData("session.active", {}), + ] + : []), + ]).pipe( + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: (request) => + owned.pipe( + Effect.map((value) => { + offers.push(request); + ownedAtOffer.push(value); + }), + ), + take: Effect.never, + }), + ); + owned = runtime.hasBufferedOutputForThread!(thread); + assert.isTrue(runtime.continuationDrainsOutput); + const terminals: Array<string> = []; + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + if (event.type === "turn.terminal") terminals.push(event.status); + }), + ), + Effect.forkScoped, + ); + const settle = Effect.gen(function* () { + for (let attempt = 0; attempt < 5_000; attempt++) yield* Effect.yieldNow; + }); + yield* runtime.startTurn(withLineage(thread)); + yield* settle; + // Offered while held, and still held once OpenCode ended it. + assert.deepEqual(ownedAtOffer, [true]); + if (leaves === "dropped by a reconnect") { + assert.isFalse(yield* owned); + return; + } + assert.isTrue(yield* owned); + if (leaves === "stopped") { + yield* runtime.interruptTurn({ + providerThread: thread, + providerTurnId: yield* providerTurnId, + requestRuntimeRestart: true, + }); + assert.isFalse(yield* owned); + return; + } + yield* runtime.startTurn({ + ...withLineage(thread), + runId: RunId.make("run:opencode2-adapter:wake"), + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make("attempt:opencode2-adapter:wake"), + message: { + ...turnInput(thread).message, + messageId: MessageId.make("message:opencode2-adapter:wake"), + createdBy: "agent" as const, + creationSource: "provider" as const, + }, + }); + yield* settle; + assert.deepEqual(terminals, ["completed", "completed"]); + assert.isFalse(yield* owned); + }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)), + ); + } }); /** The provider turn the adapter derives for `turnInput`'s attempt. */ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index f837aa029572..1b3aba536c99 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -3261,6 +3261,17 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const state = nativeId == null ? undefined : threads.get(nativeId); return state !== undefined && hasBackground(state); }), + // A held wake is output OpenCode already ran that only a continuation + // turn delivers; a Stop, a reconnect or its delivery takes it away. + hasBufferedOutputForThread: (providerThread) => + Effect.sync(() => { + const nativeId = providerThread.nativeThreadRef?.nativeId; + const state = nativeId == null ? undefined : threads.get(nativeId); + return state !== undefined && state.wakes.length > 0; + }), + // A continuation replays output that already ran and sends no model + // selection, so its start cannot acknowledge one. + continuationDrainsOutput: true, getModelContextWindow: (selection) => selection.instanceId === instanceId ? contextWindows.get(selection.model) : undefined, ensureThread: (threadInput) => diff --git a/apps/server/src/orchestration-v2/testkit/OpenCode2RollbackWakeAdmission.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OpenCode2RollbackWakeAdmission.integration.test.ts new file mode 100644 index 000000000000..a63fc1c07074 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/OpenCode2RollbackWakeAdmission.integration.test.ts @@ -0,0 +1,272 @@ +/** + * A thread whose session forgot its binding (a plain detach, here from + * settling the thread) is attached again by a rollback, which loads nothing + * the session manager sees; it has no record of the binding's selection. + * OpenCode then starts a follow-up on its own, which the adapter holds for a + * continuation. A user message that changes the selection, or moves the + * thread to another provider instance, must be refused at admission while that + * output is held: before a run or a selection change is committed, and + * without touching the held output. + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + CommandId, + MessageId, + ProviderInstanceId, + type ModelSelection, + type OrchestrationV2Command, + type ProviderReplayEntry, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { OpenCode2OrchestratorReplayHarness } from "../Adapters/OpenCode2AdapterV2.testkit.ts"; +import * as IdAllocator from "../IdAllocator.ts"; +import * as ProviderAdapter from "../ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; +import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; +import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; +import { materializeFixtureInput } from "./fixtures/shared.ts"; +import { runOrchestratorV2ProviderReplayScenario } from "./ProviderReplayHarness.ts"; +import { + materializeReplayTranscriptRuntimeInstructions, + readProviderReplayTranscript, +} from "./ReplayTranscriptNdjson.ts"; +import { checkpointWorkspace } from "./ReplayFixtureWorkspace.ts"; + +const FIXTURE = "opencode2_revert"; +const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find((candidate) => candidate.name === FIXTURE); +const provider = fixture?.providers[0]; +/** Held until both messages were refused: the last frame after the follow-up. */ +const WAKE_HELD = "wake-held"; +/** Another OpenCode instance the thread could move to. */ +const OTHER = ProviderInstanceId.make("opencode-other"); + +const labelOf = (entry: ProviderReplayEntry) => + entry.type === "runtime_exit" ? undefined : entry.label; +/** A recorded session event again, later in the stream, with its own id and sequence. */ +const again = (entry: ProviderReplayEntry, seq: number, label?: string): ProviderReplayEntry => { + if (entry.type !== "emit_inbound") return entry; + const frame = entry.frame as { readonly event: Record<string, unknown> }; + const durable = frame.event["durable"] as Record<string, unknown>; + return { + ...entry, + ...(label === undefined ? {} : { label }), + frame: { + ...frame, + event: { ...frame.event, id: `evt_wake_${seq}`, durable: { ...durable, seq } }, + }, + }; +}; + +/** An error's message and its causes' messages, for asserting on the refusal reason. */ +const causeText = (error: unknown): string => { + const parts: Array<string> = []; + let current: unknown = error; + for (let depth = 0; depth < 6 && current !== undefined && current !== null; depth++) { + if (typeof current === "string") { + parts.push(current); + break; + } + if (current instanceof Error) parts.push(current.message); + current = (current as { readonly cause?: unknown }).cause; + } + return parts.join(" | "); +}; + +describe("admission while a rollback-attached OpenCode 2 binding holds a follow-up", () => { + if (fixture === undefined || provider === undefined) return; + + it.effect("refuses a changed selection and a replacement before committing either", () => + Effect.gen(function* () { + const recorded = yield* readProviderReplayTranscript(provider.transcriptFile).pipe( + Effect.provide(NodeServices.layer), + ); + const at = (label: string) => { + const index = recorded.entries.findIndex((entry) => labelOf(entry) === label); + assert.isAtLeast(index, 0, label); + return index; + }; + const entry = (label: string) => recorded.entries[at(label)]!; + const turns = recorded.entries.slice(0, at("session.execution.succeeded.2") + 1); + const revert = recorded.entries.slice( + at("message.list.users.1"), + at("message.list.history.response.2") + 1, + ); + const sessionId = "ses_f1484bef1ffeGfSJ1e2FXLCaus"; + const out = (type: string, input?: unknown): ProviderReplayEntry => ({ + type: "expect_outbound", + frame: input === undefined ? { type } : { type, input }, + }); + const reply = (operation: string, data: unknown): ProviderReplayEntry => ({ + type: "emit_inbound", + frame: { type: "sdk.response", operation, data }, + }); + const created = entry("session.create.response"); + const sessionData = + created.type === "emit_inbound" + ? (created.frame as { readonly data: { readonly data: unknown } }).data.data + : null; + const entries: ReadonlyArray<ProviderReplayEntry> = [ + ...turns, + // Settling detached the thread, and the runtime unloaded its session. + // The rollback loads it again on its own (no load the session manager + // sees) before rolling it back. + out("session.get", { sessionID: sessionId }), + reply("session.get", { data: sessionData }), + out("permission.list", { sessionID: sessionId }), + reply("permission.list", { data: [] }), + out("session.form.list", { sessionID: sessionId }), + reply("session.form.list", { data: [] }), + // It gives the session T3's rules for the thread's mode again. + out("session.update", "<any>"), + reply("session.update", null), + ...revert, + // OpenCode then runs a follow-up on its own, with no turn of T3's. + again(entry("session.execution.started"), 40), + again(entry("session.text.ended"), 41), + again(entry("session.execution.succeeded"), 42), + again(entry("session.usage.updated"), 43, WAKE_HELD), + { type: "runtime_exit", status: "success" }, + ]; + const fixtureInput = { + steps: fixture.buildInput().steps.filter((step) => step.type === "message"), + }; + const workspace = yield* checkpointWorkspace(FIXTURE); + const transcript = yield* OpenCode2OrchestratorReplayHarness.decodeTranscript( + materializeReplayTranscriptRuntimeInstructions( + { ...recorded, scenario: `${FIXTURE}-wake-admission`, entries }, + { driver: provider.driver, model: provider.modelSelection.model }, + ), + ); + const materialized = yield* materializeFixtureInput({ + scenario: FIXTURE, + fixtureInput, + driver: provider.driver, + modelSelection: provider.modelSelection, + }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime); + const threadId = materialized.projectionThreadIds[0]!; + const message = (key: string, modelSelection: ModelSelection): OrchestrationV2Command => ({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:wake-admission:${key}`), + threadId, + messageId: MessageId.make(`message:wake-admission:${key}`), + text: `Reply with exactly: ${key}`, + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const ids = yield* IdAllocator.IdAllocatorV2; + // Settling detaches the thread from its session runtime, which forgets + // what the manager knew about the binding; the runtime stays up. + const detach: OrchestrationV2Command = { + type: "thread.settle", + commandId: CommandId.make("command:wake-admission:settle"), + threadId, + }; + const scopeId = yield* ids.allocate.checkpointScope({ threadId, name: "root" }); + const rollback: OrchestrationV2Command = { + type: "checkpoint.rollback", + restoreFiles: false, + commandId: CommandId.make("command:wake-admission:rollback"), + threadId, + scopeId, + checkpointId: yield* ids.allocate.checkpoint({ checkpointScopeId: scopeId, name: "1" }), + }; + const changed = message("changed", { + ...provider.modelSelection, + model: "opencode/mimo-v2.6-flash-free", + }); + const replaced = message("replaced", { ...provider.modelSelection, instanceId: OTHER }); + // The thread's session runtime, and another instance to move to. + const sessions: Array<ProviderAdapter.ProviderAdapterV2SessionRuntime> = []; + const harness: typeof OpenCode2OrchestratorReplayHarness = { + ...OpenCode2OrchestratorReplayHarness, + makeProviderAdapterRegistryLayer: (decoded, options) => + Layer.effect( + ProviderAdapterRegistry.ProviderAdapterRegistryV2, + Effect.gen(function* () { + const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; + const recorder = ( + adapter: ProviderAdapter.ProviderAdapterV2Shape, + ): ProviderAdapter.ProviderAdapterV2Shape => ({ + ...adapter, + openSession: (input) => + adapter + .openSession(input) + .pipe(Effect.tap((session) => Effect.sync(() => void sessions.push(session)))), + }); + return ProviderAdapterRegistry.ProviderAdapterRegistryV2.of({ + ...registry, + get: (id) => + id === OTHER + ? registry.get(provider.modelSelection.instanceId).pipe( + Effect.map((adapter) => ({ + ...adapter, + instanceId: OTHER, + openSession: () => Effect.die("The other instance never opens."), + })), + ) + : registry.get(id).pipe(Effect.map(recorder)), + list: () => registry.list().pipe(Effect.map((instances) => [...instances, OTHER])), + }); + }), + ).pipe( + Layer.provide( + OpenCode2OrchestratorReplayHarness.makeProviderAdapterRegistryLayer(decoded, options), + ), + ), + }; + const result = yield* runOrchestratorV2ProviderReplayScenario( + { + name: `${FIXTURE}/wake-admission`, + transcript, + commands: [...materialized.commands, detach, rollback, changed, replaced], + steps: [ + ...materialized.steps, + { type: "dispatch", command: detach }, + { type: "advance_clock", duration: "1 millis" }, + // The rollback attaches it again and OpenCode loads nothing new. + { type: "dispatch", command: rollback }, + { type: "advance_clock", duration: "1 millis" }, + { + type: "await_run_status", + threadId, + runId: ids.derive.run({ threadId, ordinal: 2 }), + status: "rolled_back", + }, + { type: "await_replay_gate", label: WAKE_HELD }, + { type: "dispatch_refused", command: changed, key: "changed" }, + { type: "dispatch_refused", command: replaced, key: "replaced" }, + { type: "release_replay_gate", label: WAKE_HELD }, + ], + projectionThreadIds: materialized.projectionThreadIds, + runtimePolicyOverride: { ...provider.runtimePolicyOverride, cwd: workspace }, + }, + harness, + ).pipe(provideDeterministicTestRuntime); + const projection = result.projections.get(threadId); + assert.isDefined(projection); + // Neither message made a run or changed the thread's selection. + assert.deepEqual( + projection.runs.map((run) => run.status), + ["completed", "rolled_back"], + ); + assert.deepEqual(projection.thread.modelSelection, provider.modelSelection); + for (const key of ["changed", "replaced"]) { + assert.include( + causeText(result.refusedDispatches.get(key)), + ProviderAdapter.PROVIDER_BUFFERED_OUTPUT_MESSAGE, + key, + ); + } + // The follow-up is still held for its continuation. + assert.lengthOf(sessions, 1); + const binding = projection.providerThreads[0]!; + assert.isTrue(yield* sessions[0]!.hasBufferedOutputForThread!(binding)); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), + ); +}); From bac2e3f6bac9ed4caddbb06f9989f0dd3a3cb2a0 Mon Sep 17 00:00:00 2001 From: Mike Olson <mwolson@member.fsf.org> Date: Wed, 23 Sep 2026 11:26:37 -0400 Subject: [PATCH 32/34] fix(mobile): Omit team capabilities for personal iOS builds --- apps/mobile/app.config.test.ts | 191 +++++++++++++++++++++++++++++++++ apps/mobile/app.config.ts | 20 ++-- 2 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/app.config.test.ts diff --git a/apps/mobile/app.config.test.ts b/apps/mobile/app.config.test.ts new file mode 100644 index 000000000000..6cd3b00c4304 --- /dev/null +++ b/apps/mobile/app.config.test.ts @@ -0,0 +1,191 @@ +import type { ExpoConfig } from "expo/config"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +const { loadRepoEnv } = vi.hoisted(() => ({ + loadRepoEnv: vi.fn<() => Record<string, string | undefined>>(), +})); + +vi.mock("../../scripts/lib/public-config.ts", () => ({ loadRepoEnv })); + +afterEach(() => { + vi.resetModules(); + loadRepoEnv.mockReset(); +}); + +const variants = [ + { value: undefined, name: "T3 Code", suffix: "", policy: "fingerprint" }, + { value: "production", name: "T3 Code", suffix: "", policy: "fingerprint" }, + { value: "development", name: "T3 Code Dev", suffix: ".dev", policy: "appVersion" }, + { value: "preview", name: "T3 Code Preview", suffix: ".preview", policy: "fingerprint" }, +] as const; + +const personalBundleIdentifier = "com.example.t3code.personal"; + +describe("mobile app configuration", () => { + for (const variant of variants) { + describe(variant.value ?? "default release", () => { + it("retains the standard identity and team capabilities by default", async () => { + const config = await evaluateConfig({ APP_VARIANT: variant.value }); + const bundleIdentifier = `com.t3tools.t3code${variant.suffix}`; + + expect(config.name).toBe(variant.name); + expect(config.scheme).toBe(`t3code${variant.suffix.replace(".", "-")}`); + expect(config.runtimeVersion).toEqual({ policy: variant.policy }); + expect(config.ios).toMatchObject({ + bundleIdentifier, + appleTeamId: "ARK85ZXQ4Z", + associatedDomains: ["applinks:clerk.t3.codes", "webcredentials:clerk.t3.codes"], + entitlements: { + "keychain-access-groups": [`$(AppIdentifierPrefix)${bundleIdentifier}`], + }, + }); + expect(config.android?.package).toBe(bundleIdentifier); + expect(config.extra?.iosPersonalTeamBuild).toBe(false); + expect(plugin(config, "@clerk/expo")).toEqual([ + "@clerk/expo", + { theme: "./clerk-theme.json", appleSignIn: true }, + ]); + expect(plugin(config, "expo-widgets")).toEqual([ + "expo-widgets", + expect.objectContaining({ + bundleIdentifier: `${bundleIdentifier}.widgets`, + groupIdentifier: `group.${bundleIdentifier}`, + }), + ]); + expect(plugin(config, "expo-sharing")).toEqual([ + "expo-sharing", + expect.objectContaining({ + ios: expect.objectContaining({ + enabled: true, + extensionBundleIdentifier: `${bundleIdentifier}.sharing`, + appGroupId: `group.${bundleIdentifier}`, + }), + }), + ]); + expect(plugin(config, "./plugins/withShareExtensionDisplayName.cjs")).toBeDefined(); + expect(plugin(config, "./plugins/withoutIosPersonalTeamCapabilities.cjs")).toBeUndefined(); + }); + + it.each(["", "0", "true"])( + "requires explicit opt-in, not flag %j or a bundle override alone", + async (flag) => { + const standard = await evaluateConfig({ APP_VARIANT: variant.value }); + const flagOff = await evaluateConfig({ + APP_VARIANT: variant.value, + T3CODE_IOS_PERSONAL_TEAM: flag, + T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID: "invalid bundle ignored without opt-in", + }); + expect(flagOff).toEqual(standard); + expect( + await evaluateConfig({ + APP_VARIANT: variant.value, + T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID: personalBundleIdentifier, + }), + ).toEqual(standard); + }, + ); + + it("uses the explicit personal identifier and omits unsupported team capabilities", async () => { + const standard = await evaluateConfig({ APP_VARIANT: variant.value }); + const config = await evaluateConfig({ + APP_VARIANT: variant.value, + T3CODE_IOS_PERSONAL_TEAM: "1", + T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID: ` ${personalBundleIdentifier} `, + }); + + expect(config.ios?.bundleIdentifier).toBe(personalBundleIdentifier); + expect(config.ios).not.toHaveProperty("appleTeamId"); + expect(config.ios).not.toHaveProperty("associatedDomains"); + expect(config.ios).not.toHaveProperty("entitlements"); + const expectedIos = { ...standard.ios, bundleIdentifier: personalBundleIdentifier }; + delete expectedIos.appleTeamId; + delete expectedIos.associatedDomains; + delete expectedIos.entitlements; + expect(config.ios).toEqual(expectedIos); + expect(config.extra?.iosPersonalTeamBuild).toBe(true); + expect(plugin(config, "expo-widgets")).toBeUndefined(); + expect(plugin(config, "./plugins/withWidgetLogoAsset.cjs")).toBeUndefined(); + expect(plugin(config, "./plugins/withShareExtensionDisplayName.cjs")).toBeUndefined(); + expect(plugin(config, "@clerk/expo")).toEqual([ + "@clerk/expo", + { theme: "./clerk-theme.json", appleSignIn: false }, + ]); + expect(plugin(config, "expo-sharing")).toEqual([ + "expo-sharing", + expect.objectContaining({ + ios: expect.objectContaining({ + enabled: false, + extensionBundleIdentifier: `${personalBundleIdentifier}.sharing`, + appGroupId: `group.${personalBundleIdentifier}`, + }), + }), + ]); + expect(plugin(config, "./plugins/withoutIosPersonalTeamCapabilities.cjs")).toBeDefined(); + }); + + it("leaves Android values and plugins unchanged by the iOS opt-in", async () => { + const env = { + APP_VARIANT: variant.value, + T3CODE_ANDROID_GOOGLE_SERVICES_FILE: "./fixture-google-services.json", + }; + const standard = await evaluateConfig(env); + const personal = await evaluateConfig({ + ...env, + T3CODE_IOS_PERSONAL_TEAM: "1", + T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID: personalBundleIdentifier, + }); + + expect(personal.android).toEqual(standard.android); + expect(personal.android?.googleServicesFile).toBe(env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE); + expect(androidPlugins(personal)).toEqual(androidPlugins(standard)); + expect(plugin(personal, "expo-notifications")).toEqual( + plugin(standard, "expo-notifications"), + ); + expect(plugin(personal, "expo-quick-actions")).toEqual( + plugin(standard, "expo-quick-actions"), + ); + }); + }); + } + + it.each([undefined, "", " ", "singleword", "com..example", "com.example_bad", "com.example.*"])( + "rejects missing or invalid personal bundle identifier %j", + async (bundleIdentifier) => { + await expect( + evaluateConfig({ + T3CODE_IOS_PERSONAL_TEAM: "1", + T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID: bundleIdentifier, + }), + ).rejects.toThrow( + "T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID must be a reverse-DNS identifier such as com.example.t3code when T3CODE_IOS_PERSONAL_TEAM=1.", + ); + }, + ); +}); + +async function evaluateConfig(env: Record<string, string | undefined>) { + const originalEnv = process.env; + // Evaluate the real module afresh without reading repository env files or host values. + process.env = { NODE_ENV: "test" }; + loadRepoEnv.mockReturnValue(env); + vi.resetModules(); + try { + return (await import("./app.config.ts")).default; + } finally { + process.env = originalEnv; + } +} + +function plugin(config: ExpoConfig, name: string) { + return config.plugins?.find((entry) => (Array.isArray(entry) ? entry[0] : entry) === name); +} + +function androidPlugins(config: ExpoConfig) { + return config.plugins?.flatMap<{ name: string; android?: unknown }>((entry) => { + if (typeof entry === "string") { + return entry.startsWith("./plugins/withAndroid") ? [{ name: entry }] : []; + } + if (entry[0] === undefined || entry[1]?.android === undefined) return []; + return [{ name: entry[0], android: entry[1].android }]; + }); +} diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 52eae6db9777..53a315a4d92e 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -240,14 +240,18 @@ const config: ExpoConfig = { // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` // does not fall back to a personal team (which cannot sign app groups, // Sign in with Apple, or push notification entitlements). - appleTeamId: "ARK85ZXQ4Z", - associatedDomains: [ - `applinks:${variant.relyingParty}`, - `webcredentials:${variant.relyingParty}`, - ], - entitlements: { - "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`], - }, + ...(isIosPersonalTeamBuild + ? {} + : { + appleTeamId: "ARK85ZXQ4Z", + associatedDomains: [ + `applinks:${variant.relyingParty}`, + `webcredentials:${variant.relyingParty}`, + ], + entitlements: { + "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`], + }, + }), infoPlist: { NSAppTransportSecurity: { NSAllowsArbitraryLoads: true, From 8ef1920df2c3eec6599a38245e4ab6d5cb234ae1 Mon Sep 17 00:00:00 2001 From: Mike Olson <mwolson@member.fsf.org> Date: Mon, 28 Sep 2026 21:01:27 -0400 Subject: [PATCH 33/34] fix(desktop): Keep the Clerk token file owner-only @clerk/electron stores tokens through electron-store, whose conf writer chmods clerk-tokens.json to 0666 on every write regardless of umask. Patch the storage adapter to write 0600 and to tighten an existing file when it opens, until Clerk sets the mode itself. --- .../desktop/src/app/ClerkTokenStorage.test.ts | 54 +++++++++++++++++++ patches/@clerk__electron@0.0.44.patch | 43 +++++++++++++++ pnpm-lock.yaml | 7 +-- pnpm-workspace.yaml | 2 + 4 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/app/ClerkTokenStorage.test.ts create mode 100644 patches/@clerk__electron@0.0.44.patch diff --git a/apps/desktop/src/app/ClerkTokenStorage.test.ts b/apps/desktop/src/app/ClerkTokenStorage.test.ts new file mode 100644 index 000000000000..4ae767f656cc --- /dev/null +++ b/apps/desktop/src/app/ClerkTokenStorage.test.ts @@ -0,0 +1,54 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { isHostWindows } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import { vi } from "vite-plus/test"; + +vi.mock("electron", () => ({ + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString(), + }, +})); + +import { storage } from "@clerk/electron/storage"; + +const fileMode = (path: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const info = yield* fileSystem.stat(path); + return info.mode & 0o777; + }); + +describe("Clerk token storage", () => { + it.effect("writes the token file readable and writable only by its owner", () => + Effect.gen(function* () { + if (yield* isHostWindows) return; + const fileSystem = yield* FileSystem.FileSystem; + const stateDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-clerk-tokens-" }); + + yield* Effect.promise(async () => { + await storage({ path: stateDir }).setItem("__clerk_client_jwt", "t"); + }); + + assert.equal(yield* fileMode(`${stateDir}/clerk-tokens.json`), 0o600); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("tightens a token file left world-writable by an earlier version", () => + Effect.gen(function* () { + if (yield* isHostWindows) return; + const fileSystem = yield* FileSystem.FileSystem; + const stateDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-clerk-tokens-" }); + const tokenFile = `${stateDir}/clerk-tokens.json`; + yield* fileSystem.writeFileString(tokenFile, "{}"); + yield* fileSystem.chmod(tokenFile, 0o666); + + storage({ path: stateDir }); + + assert.equal(yield* fileMode(tokenFile), 0o600); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); diff --git a/patches/@clerk__electron@0.0.44.patch b/patches/@clerk__electron@0.0.44.patch new file mode 100644 index 000000000000..7909c8b83915 --- /dev/null +++ b/patches/@clerk__electron@0.0.44.patch @@ -0,0 +1,43 @@ +diff --git a/dist/cjs/storage/index.js b/dist/cjs/storage/index.js +index 7adc3a0df931931393e2336631f3b3f4c60fdbab..f628e546e5514e039b2bf91e87385d28a4e9c9e2 100644 +--- a/dist/cjs/storage/index.js ++++ b/dist/cjs/storage/index.js +@@ -98,8 +98,12 @@ async function resolveCipher() { + function storage(options = {}) { + const store = new electron_store.default({ + name: options.name ?? "clerk-tokens", +- ...options.path ? { cwd: options.path } : {} ++ ...options.path ? { cwd: options.path } : {}, ++ configFileMode: 0o600 + }); ++ try { ++ require("node:fs").chmodSync(store.path, 0o600); ++ } catch {} + const memoryFallback = /* @__PURE__ */ new Map(); + const mutationVersions = /* @__PURE__ */ new Map(); + const beginMutation = (key) => { +diff --git a/dist/esm/storage/index.js b/dist/esm/storage/index.js +index d289bd84ca4dc6a5e2fc4c6b8f9de5bef1d7a175..195996714ecc36d818ecd480fcdeaf8e5a830d4d 100644 +--- a/dist/esm/storage/index.js ++++ b/dist/esm/storage/index.js +@@ -1,5 +1,6 @@ + import { safeStorage } from "electron"; + import Store from "electron-store"; ++import { chmodSync } from "node:fs"; + + //#region src/storage/index.ts + /** +@@ -69,8 +70,12 @@ async function resolveCipher() { + function storage(options = {}) { + const store = new Store({ + name: options.name ?? "clerk-tokens", +- ...options.path ? { cwd: options.path } : {} ++ ...options.path ? { cwd: options.path } : {}, ++ configFileMode: 0o600 + }); ++ try { ++ chmodSync(store.path, 0o600); ++ } catch {} + const memoryFallback = /* @__PURE__ */ new Map(); + const mutationVersions = /* @__PURE__ */ new Map(); + const beginMutation = (key) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c36cf244f9bb..cad9e8a348f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,7 @@ overrides: packageExtensionsChecksum: sha256-k/dT9NFDl5hihRPaoFKeY11hzyutMFs5psfZLFiKJic= patchedDependencies: + '@clerk/electron@0.0.44': ecb83f06bcaf6dd0858e6f7efd2a9de74fdcee1700fc0e4bca4a23bc92018808 '@clerk/expo@4.6.8': a82bb41039ee88a290a87d4ab58be7be4c49fa338603b17ea646e1ec140a1cfb '@effect/vitest@4.0.0-rc.115': 172bd95ef73a13d322888984415d5d18d9d2aa8e13fa529eac235efac837e902 '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 @@ -146,7 +147,7 @@ importers: dependencies: '@clerk/electron': specifier: 0.0.44 - version: 0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 0.0.44(patch_hash=ecb83f06bcaf6dd0858e6f7efd2a9de74fdcee1700fc0e4bca4a23bc92018808)(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron-passkeys': specifier: 0.0.3 version: 0.0.3 @@ -612,7 +613,7 @@ importers: version: 1.5.0(@date-fns/tz@1.5.0)(@types/react@19.2.16)(date-fns@4.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron': specifier: 0.0.44 - version: 0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 0.0.44(patch_hash=ecb83f06bcaf6dd0858e6f7efd2a9de74fdcee1700fc0e4bca4a23bc92018808)(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': specifier: 6.16.1 version: 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -12397,7 +12398,7 @@ snapshots: '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 - '@clerk/electron@0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/electron@0.0.44(patch_hash=ecb83f06bcaf6dd0858e6f7efd2a9de74fdcee1700fc0e4bca4a23bc92018808)(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@clerk/clerk-js': 6.32.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 25c6a1c14f16..6719420c1597 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -187,6 +187,8 @@ packageExtensions: vite: "catalog:" patchedDependencies: + # Keep Clerk's token file owner-only; electron-store writes 0666 by default. + "@clerk/electron@0.0.44": patches/@clerk__electron@0.0.44.patch "@clerk/expo@4.6.8": patches/@clerk__expo@4.6.8.patch "@effect/vitest@4.0.0-rc.115": patches/@effect__vitest@4.0.0-rc.115.patch "@expo/metro-config@57.0.12": patches/@expo__metro-config@57.0.12.patch From 17784269ac39dd8b76ad7a13d15aec891a86dadc Mon Sep 17 00:00:00 2001 From: Mike Olson <mwolson@member.fsf.org> Date: Tue, 29 Sep 2026 10:23:01 -0400 Subject: [PATCH 34/34] test(desktop): Keep WSL busy-runtime fixtures visible when sh is bash The executed WSL cache tests held a runtime busy with `sh -c "sleep 30" <runtime>/t3`. Where /bin/sh is bash, a lone -c command is exec'd in place, the runtime path drops out of argv, and the prune script's /proc cmdline scan treats the cache as idle. Two tests then fail on Arch and Fedora hosts. Hold the runtime with a process whose argv[0] is the runtime entry, and hold the install lock the same way. Both read a pipe the test script keeps open, so they exit with the script instead of outliving it for 30 seconds, and the tests wait until each holder is in place instead of sleeping. --- .../src/wsl/DesktopWslEnvironment.test.ts | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index e28a9c6c7800..e261524f9ac9 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -69,6 +69,28 @@ const runShell = (script: string) => { const sh = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; +// Polls for up to five seconds until a background holder has taken effect. +const awaitHolder = (condition: string) => + `i=0; until ${condition}; do i=$((i + 1)); [ "$i" -lt 500 ] || exit 1; sleep 0.01; done`; + +// Holds a cache busy the way a running server does: the holder's argv[0] is the +// runtime entry, which the prune and install scripts look for in /proc cmdline. +// It reads a pipe the script keeps open on fd 7, so it exits with the script +// instead of outliving it on a timer. +const holdRuntimeBusy = (entry: string) => + [ + `exec 7> >(exec -a ${entry} cat >/dev/null 2>&1)`, + awaitHolder(`grep -qF -- ${entry} /proc/[0-9]*/cmdline 2>/dev/null`), + ].join("\n"); + +// Holds a cache's install lock the way a concurrent install does, and likewise +// releases it when the script exits. +const holdInstallLock = (lock: string) => + [ + `exec 6> >(exec 9> ${lock}; flock -x 9; cat >/dev/null 2>&1)`, + awaitHolder(`! flock -n ${lock} true`), + ].join("\n"); + const readField = (stdout: string, field: string) => { const line = stdout.split("\n").find((candidate) => candidate.startsWith(`${field}:`)); if (line === undefined) throw new Error(`missing ${field} in fixture output: ${stdout}`); @@ -712,9 +734,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed `runtime_root=${sh(fixture.runtimeRoot)}`, `runtime_parent=${sh(fixture.runtimeParent)}`, 'rm "$runtime_root/.t3code-wsl-runtime-ready"', - 'sh -c "sleep 30" "$runtime_root/t3" >/dev/null 2>&1 &', - "active_pid=$!", - "sleep 0.1", + holdRuntimeBusy('"$runtime_root/t3"'), fixture.installScript(), 'stale=$(find "$runtime_parent" -maxdepth 1 -type d -name ".sha256-*.stale.*" -print -quit)', 'test -n "$stale"', @@ -723,8 +743,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed "export HOME", buildWslRuntimePruneScript(fixture.runtimeId), 'test ! -e "$stale"', - "kill $active_pid", - "wait $active_pid 2>/dev/null || true", ].join("\n"), ); @@ -750,15 +768,8 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"', 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"', 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"', - 'sh -c "sleep 30" "$runtime_parent/sha256-active/t3" >/dev/null 2>&1 &', - "active_pid=$!", - "(", - ' exec 9> "$runtime_parent/.sha256-locked.install.lock"', - " flock -x 9", - " sleep 30", - ") >/dev/null 2>&1 &", - "lock_pid=$!", - "sleep 0.1", + holdRuntimeBusy('"$runtime_parent/sha256-active/t3"'), + holdInstallLock('"$runtime_parent/.sha256-locked.install.lock"'), `HOME="$home"`, "export HOME", buildWslRuntimePruneScript("sha256-current"), @@ -769,9 +780,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed 'test -d "$runtime_parent/versions"', 'test ! -e "$runtime_parent/sha256-old"', 'test ! -e "$runtime_parent/sha256-markerless"', - "kill $active_pid $lock_pid", - "wait $active_pid 2>/dev/null || true", - "wait $lock_pid 2>/dev/null || true", 'rm -rf "$work"', ].join("\n"), );