From 6188c82b323870c98927d529340dc395ae14f197 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 2 Aug 2026 10:47:30 -0400 Subject: [PATCH] feat(orchestrator): Add OpenCode 2 provider support --- apps/mobile/src/components/ProviderIcon.tsx | 25 +- .../src/components/providerIconKind.test.ts | 25 + .../mobile/src/components/providerIconKind.ts | 21 + .../features/threads/PendingUserInputCard.tsx | 5 +- .../threads/ThreadComposer.logic.test.ts | 32 + .../features/threads/ThreadComposer.logic.ts | 20 + .../src/features/threads/ThreadComposer.tsx | 50 +- .../features/threads/ThreadDetailScreen.tsx | 2 +- .../features/threads/ThreadRouteScreen.tsx | 13 +- apps/mobile/src/lib/threadActivity.test.ts | 50 + apps/mobile/src/lib/threadActivity.ts | 62 +- .../src/state/use-selected-thread-requests.ts | 21 +- .../src/state/use-thread-composer-state.ts | 8 +- apps/server/package.json | 1 + .../src/mcp/OrchestratorMcpService.test.ts | 52 + apps/server/src/mcp/OrchestratorMcpService.ts | 39 +- .../mcp/toolkits/orchestrator/tools.test.ts | 17 +- .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../AcpRegistryOrchestratorV2.live.test.ts | 6 + .../Adapters/AcpAdapterV2.test.ts | 3 + .../orchestration-v2/Adapters/AcpAdapterV2.ts | 1 + .../Adapters/CodexAdapterV2.ts | 1 + .../Adapters/GrokAdapterV2.test.ts | 51 + .../Adapters/GrokAdapterV2.ts | 1 + .../OpenCode2AdapterV2.agentSelection.test.ts | 297 + .../Adapters/OpenCode2AdapterV2.forms.test.ts | 141 + .../Adapters/OpenCode2AdapterV2.live.test.ts | 358 ++ .../Adapters/OpenCode2AdapterV2.test.ts | 993 ++++ .../OpenCode2AdapterV2.testkit.test.ts | 360 ++ .../Adapters/OpenCode2AdapterV2.testkit.ts | 523 ++ .../Adapters/OpenCode2AdapterV2.ts | 5080 +++++++++++++++++ .../Adapters/OpenCodeAdapterV2.ts | 21 + .../CursorOrchestratorV2.live.test.ts | 6 + .../src/orchestration-v2/EffectOutbox.ts | 9 + .../src/orchestration-v2/EffectWorker.ts | 12 + .../GrokOrchestratorV2.live.test.ts | 6 + .../Orchestrator.settled.test.ts | 969 ++++ .../src/orchestration-v2/Orchestrator.ts | 235 +- .../src/orchestration-v2/ProjectionStore.ts | 69 + .../src/orchestration-v2/ProviderAdapter.ts | 23 + ...erInteractionModeReflectionService.test.ts | 317 + ...roviderInteractionModeReflectionService.ts | 70 + .../ProviderInteractionModeReflections.ts | 45 + .../ProviderSessionManager.test.ts | 272 + .../ProviderSessionManager.ts | 160 +- .../orchestration-v2/RunExecutionService.ts | 14 +- .../ThreadManagementService.test.ts | 139 +- .../ThreadManagementService.ts | 62 +- .../builtInProviderAdapterDrivers.ts | 6 + .../src/orchestration-v2/runtimeLayer.test.ts | 10 + .../src/orchestration-v2/runtimeLayer.ts | 8 + ...estratorReplayFixtures.integration.test.ts | 6 + .../testkit/OrchestratorScenario.ts | 38 + .../testkit/fixtures/index.ts | 426 ++ .../multi_turn/opencode2_transcript.ndjson | 32 + .../input.ts | 13 + .../opencode2_transcript.ndjson | 40 + .../output.ts | 27 + .../opencode2_background_child_stop/input.ts | 14 + .../opencode2_transcript.ndjson | 62 + .../opencode2_background_child_stop/output.ts | 77 + .../input.ts | 14 + .../opencode2_transcript.ndjson | 57 + .../output.ts | 75 + .../input.ts | 14 + .../opencode2_transcript.ndjson | 59 + .../output.ts | 46 + .../opencode2_background_stop/input.ts | 10 + .../opencode2_transcript.ndjson | 21 + .../opencode2_background_stop/output.ts | 54 + .../fixtures/opencode2_compaction/input.ts | 15 + .../opencode2_transcript.ndjson | 32 + .../fixtures/opencode2_compaction/output.ts | 56 + .../input.ts | 7 + .../opencode2_transcript.ndjson | 33 + .../output.ts | 37 + .../input.ts | 10 + .../opencode2_transcript.ndjson | 25 + .../output.ts | 24 + .../input.ts | 10 + .../opencode2_transcript.ndjson | 35 + .../output.ts | 39 + .../opencode2_permission_session/input.ts | 10 + .../opencode2_transcript.ndjson | 26 + .../opencode2_permission_session/output.ts | 24 + .../opencode2_question_legacy/input.ts | 16 + .../opencode2_transcript.ndjson | 23 + .../opencode2_question_legacy/output.ts | 32 + .../opencode2_retired_suppress_wake/input.ts | 11 + .../opencode2_transcript.ndjson | 52 + .../opencode2_retired_suppress_wake/output.ts | 54 + .../testkit/fixtures/opencode2_retry/input.ts | 7 + .../opencode2_transcript.ndjson | 20 + .../fixtures/opencode2_retry/output.ts | 39 + .../input.ts | 14 + .../opencode2_transcript.ndjson | 37 + .../output.ts | 38 + .../input.ts | 10 + .../opencode2_transcript.ndjson | 26 + .../output.ts | 48 + .../opencode2_shell_projection/input.ts | 7 + .../opencode2_transcript.ndjson | 28 + .../opencode2_shell_projection/output.ts | 37 + .../opencode2_shell_terminals/input.ts | 14 + .../opencode2_transcript.ndjson | 41 + .../opencode2_shell_terminals/output.ts | 52 + .../input.ts | 10 + .../opencode2_transcript.ndjson | 43 + .../output.ts | 48 + .../opencode2_subagent_queued_turn/input.ts | 15 + .../opencode2_transcript.ndjson | 54 + .../opencode2_subagent_queued_turn/output.ts | 84 + .../opencode2_subagent_rate_limit/input.ts | 7 + .../opencode2_transcript.ndjson | 30 + .../opencode2_subagent_rate_limit/output.ts | 37 + .../opencode2_subagent_supervised/input.ts | 10 + .../opencode2_transcript.ndjson | 33 + .../opencode2_subagent_supervised/output.ts | 37 + .../fixtures/opencode2_thread_delete/input.ts | 11 + .../opencode2_transcript.ndjson | 24 + .../opencode2_thread_delete/output.ts | 48 + .../input.ts | 14 + .../opencode2_transcript.ndjson | 64 + .../output.ts | 52 + .../input.ts | 17 + .../opencode2_transcript.ndjson | 69 + .../output.ts | 77 + .../queued_turn/opencode2_transcript.ndjson | 31 + .../testkit/fixtures/shared.ts | 117 +- .../simple/opencode2_transcript.ndjson | 22 + .../provider/Drivers/OpenCode2Driver.test.ts | 86 + .../src/provider/Drivers/OpenCode2Driver.ts | 222 + .../OpenCode2Provider.agentSelection.test.ts | 109 + .../provider/Layers/OpenCode2Provider.test.ts | 629 ++ .../src/provider/Layers/OpenCode2Provider.ts | 618 ++ .../src/provider/Layers/OpenCodeProvider.ts | 19 +- .../ProviderInstanceRegistryLive.test.ts | 8 +- ...viderOrchestrationAdapterInfrastructure.ts | 10 +- .../provider/Layers/ProviderRegistry.test.ts | 139 + .../src/provider/Layers/ProviderRegistry.ts | 22 +- .../OpenCode2ProviderEnvironment.test.ts | 46 + .../provider/OpenCode2ProviderEnvironment.ts | 17 + .../src/provider/SpawnedProcessReaper.test.ts | 580 ++ .../src/provider/SpawnedProcessReaper.ts | 452 ++ apps/server/src/provider/builtInDrivers.ts | 3 + .../makeManagedServerProvider.test.ts | 89 + .../src/provider/makeManagedServerProvider.ts | 34 +- .../provider/opencode2Runtime.live.test.ts | 87 + .../src/provider/opencode2Runtime.test.ts | 572 ++ apps/server/src/provider/opencode2Runtime.ts | 604 ++ .../src/provider/providerMaintenance.test.ts | 85 +- .../src/provider/providerMaintenance.ts | 71 +- apps/server/src/provider/providerSnapshot.ts | 17 + .../src/provider/providerStatusCache.test.ts | 189 + .../src/provider/providerStatusCache.ts | 89 +- apps/server/src/server.ts | 6 + .../OpenCode2TextGeneration.live.test.ts | 78 + .../OpenCode2TextGeneration.test.ts | 573 ++ .../textGeneration/OpenCode2TextGeneration.ts | 511 ++ .../src/textGeneration/TextGeneration.ts | 8 +- apps/web/src/components/ChatView.tsx | 6 + apps/web/src/components/Icons.test.tsx | 35 + apps/web/src/components/Icons.tsx | 59 +- apps/web/src/components/chat/ChatComposer.tsx | 13 +- .../chat/ComposerPrimaryActions.test.ts | 134 + .../chat/ComposerPrimaryActions.tsx | 93 +- .../components/chat/MessagesTimeline.test.tsx | 51 + apps/web/src/components/chat/ModelListRow.tsx | 2 +- .../components/chat/ModelPickerContent.tsx | 47 +- .../chat/ModelPickerSidebar.test.tsx | 129 + .../components/chat/ModelPickerSidebar.tsx | 55 +- .../chat/ProviderInstanceIcon.test.tsx | 38 + .../components/chat/ProviderModelPicker.tsx | 11 +- .../src/components/chat/V2LifecycleRow.tsx | 35 +- .../components/chat/modelPickerScope.test.ts | 49 + .../src/components/chat/modelPickerScope.ts | 16 + .../components/chat/providerIconUtils.test.ts | 60 + .../src/components/chat/providerIconUtils.ts | 20 +- .../settings/ProviderSettingsForm.test.ts | 18 + .../components/settings/providerDriverMeta.ts | 10 + apps/web/src/pendingUserInput.test.ts | 12 + apps/web/src/pendingUserInput.ts | 9 +- apps/web/src/providerInstances.test.ts | 93 +- apps/web/src/providerInstances.ts | 30 +- docs/internals/providers.md | 22 +- docs/user/install.md | 21 +- .../src/operations/commands.test.ts | 215 +- .../client-runtime/src/operations/commands.ts | 29 +- .../client-runtime/src/state/entities.test.ts | 79 + packages/client-runtime/src/state/models.ts | 3 + .../src/state/orchestrationV2Projection.ts | 3 + .../src/state/threadExecution.test.ts | 212 +- .../src/state/threadExecution.ts | 54 +- .../src/state/threadRequests.ts | 2 +- packages/contracts/src/model.ts | 3 + .../contracts/src/orchestrationV2.test.ts | 39 + packages/contracts/src/orchestrationV2.ts | 58 + packages/contracts/src/settings.ts | 81 + packages/shared/src/semver.test.ts | 4 + packages/shared/src/semver.ts | 11 +- pnpm-lock.yaml | 10 + 201 files changed, 21268 insertions(+), 317 deletions(-) create mode 100644 apps/mobile/src/components/providerIconKind.test.ts create mode 100644 apps/mobile/src/components/providerIconKind.ts create mode 100644 apps/mobile/src/features/threads/ThreadComposer.logic.test.ts create mode 100644 apps/mobile/src/features/threads/ThreadComposer.logic.ts create mode 100644 apps/server/src/mcp/OrchestratorMcpService.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.agentSelection.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.forms.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.live.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts create mode 100644 apps/server/src/orchestration-v2/Orchestrator.settled.test.ts create mode 100644 apps/server/src/orchestration-v2/ProviderInteractionModeReflectionService.test.ts create mode 100644 apps/server/src/orchestration-v2/ProviderInteractionModeReflectionService.ts create mode 100644 apps/server/src/orchestration-v2/ProviderInteractionModeReflections.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_ambiguous_execution_wakes/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_ambiguous_execution_wakes/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_ambiguous_execution_wakes/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_order/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_order/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_order/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_race/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_race/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_race/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_stop/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_stop/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_stop/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/opencode2_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_permission_external_subagent/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_external_subagent/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_external_subagent/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure_subagent/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure_subagent/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure_subagent/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_session/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_session/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_session/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question_legacy/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question_legacy/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question_legacy/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retired_suppress_wake/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retired_suppress_wake/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retired_suppress_wake/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retry/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retry/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retry/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_execution_replay/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_execution_replay/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_execution_replay/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_ordinary_wake_replay/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_ordinary_wake_replay/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_ordinary_wake_replay/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_projection/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_projection/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_projection/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_terminals/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_terminals/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_terminals/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_background_wake/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_background_wake/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_background_wake/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_queued_turn/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_queued_turn/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_queued_turn/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_rate_limit/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_rate_limit/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_rate_limit/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_supervised/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_supervised/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_supervised/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_thread_delete/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_thread_delete/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_thread_delete/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_replay/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_replay/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_replay/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_stop/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_stop/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_stop/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/opencode2_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/simple/opencode2_transcript.ndjson create mode 100644 apps/server/src/provider/Drivers/OpenCode2Driver.test.ts create mode 100644 apps/server/src/provider/Drivers/OpenCode2Driver.ts create mode 100644 apps/server/src/provider/Layers/OpenCode2Provider.agentSelection.test.ts create mode 100644 apps/server/src/provider/Layers/OpenCode2Provider.test.ts create mode 100644 apps/server/src/provider/Layers/OpenCode2Provider.ts create mode 100644 apps/server/src/provider/OpenCode2ProviderEnvironment.test.ts create mode 100644 apps/server/src/provider/OpenCode2ProviderEnvironment.ts create mode 100644 apps/server/src/provider/SpawnedProcessReaper.test.ts create mode 100644 apps/server/src/provider/SpawnedProcessReaper.ts create mode 100644 apps/server/src/provider/opencode2Runtime.live.test.ts create mode 100644 apps/server/src/provider/opencode2Runtime.test.ts create mode 100644 apps/server/src/provider/opencode2Runtime.ts create mode 100644 apps/server/src/textGeneration/OpenCode2TextGeneration.live.test.ts create mode 100644 apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts create mode 100644 apps/server/src/textGeneration/OpenCode2TextGeneration.ts create mode 100644 apps/web/src/components/Icons.test.tsx create mode 100644 apps/web/src/components/chat/ModelPickerSidebar.test.tsx create mode 100644 apps/web/src/components/chat/ProviderInstanceIcon.test.tsx create mode 100644 apps/web/src/components/chat/modelPickerScope.test.ts create mode 100644 apps/web/src/components/chat/modelPickerScope.ts create mode 100644 apps/web/src/components/chat/providerIconUtils.test.ts diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 6c1b1038698..8fda1c7d0d4 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,6 +1,8 @@ import { useColorScheme } from "react-native"; import { Path, Svg } from "react-native-svg"; +import { providerIconKind, providerIconPalette } from "./providerIconKind"; + type ProviderIconProps = { readonly provider: string | null | undefined; readonly size?: number; @@ -9,8 +11,9 @@ type ProviderIconProps = { export function ProviderIcon(props: ProviderIconProps) { const isDarkMode = useColorScheme() === "dark"; const size = props.size ?? 16; + const iconKind = providerIconKind(props.provider); - if (props.provider === "claudeAgent") { + if (iconKind === "claude") { return ( + {iconKind === "opencode2" ? ( + <> + + + + + ) : ( + + )} + + + ); + } + return ( { + it("keeps both OpenCode generations distinct", () => { + assert.strictEqual(providerIconKind("opencode"), "opencode"); + assert.strictEqual(providerIconKind("opencode2"), "opencode2"); + }); + + it("preserves existing provider fallbacks", () => { + assert.strictEqual(providerIconKind("claudeAgent"), "claude"); + assert.strictEqual(providerIconKind("codex"), "openai"); + assert.strictEqual(providerIconKind(undefined), "openai"); + }); +}); + +describe("providerIconPalette", () => { + it("keeps both OpenCode outer frames contrasted with the application theme", () => { + assert.strictEqual(providerIconPalette("opencode", false), "light"); + assert.strictEqual(providerIconPalette("opencode", true), "dark"); + assert.strictEqual(providerIconPalette("opencode2", false), "light"); + assert.strictEqual(providerIconPalette("opencode2", true), "dark"); + }); +}); diff --git a/apps/mobile/src/components/providerIconKind.ts b/apps/mobile/src/components/providerIconKind.ts new file mode 100644 index 00000000000..b5435b68c17 --- /dev/null +++ b/apps/mobile/src/components/providerIconKind.ts @@ -0,0 +1,21 @@ +export type ProviderIconKind = "claude" | "opencode" | "opencode2" | "openai"; + +export function providerIconKind(provider: string | null | undefined): ProviderIconKind { + switch (provider) { + case "claudeAgent": + return "claude"; + case "opencode": + return "opencode"; + case "opencode2": + return "opencode2"; + default: + return "openai"; + } +} + +export function providerIconPalette( + _kind: ProviderIconKind, + isDarkMode: boolean, +): "light" | "dark" { + return isDarkMode ? "dark" : "light"; +} diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 8bcdd80451b..2ec83bb20e4 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -8,7 +8,7 @@ import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/th export interface PendingUserInputCardProps { readonly pendingUserInput: PendingUserInput; readonly drafts: Record; - readonly answers: Record | null; + readonly answers: Record | null; readonly respondingUserInputId: RuntimeRequestId | null; readonly onSelectOption: (requestId: RuntimeRequestId, questionId: string, label: string) => void; readonly onChangeCustomAnswer: ( @@ -48,7 +48,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {question.options.map((option) => { const selected = - draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length; + draft?.selectedOptionLabels?.includes(option.label) === true && + !draft.customAnswer?.trim().length; return ( { + it("keeps Send primary and exposes Stop beside a settled draft", () => { + expect( + collapsedComposerActions({ + canStopThread: true, + hasContent: true, + activeThreadBusy: false, + }), + ).toEqual({ showStopPrimary: false, showStopSecondary: true }); + }); + + it("uses Stop as the primary action when the draft is empty or busy", () => { + expect( + collapsedComposerActions({ + canStopThread: true, + hasContent: false, + activeThreadBusy: false, + }), + ).toEqual({ showStopPrimary: true, showStopSecondary: false }); + expect( + collapsedComposerActions({ + canStopThread: true, + hasContent: true, + activeThreadBusy: true, + }), + ).toEqual({ showStopPrimary: true, showStopSecondary: false }); + }); +}); diff --git a/apps/mobile/src/features/threads/ThreadComposer.logic.ts b/apps/mobile/src/features/threads/ThreadComposer.logic.ts new file mode 100644 index 00000000000..356f8e3e9e3 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadComposer.logic.ts @@ -0,0 +1,20 @@ +export interface CollapsedComposerActionsInput { + readonly canStopThread: boolean; + readonly hasContent: boolean; + readonly activeThreadBusy: boolean; +} + +export interface CollapsedComposerActions { + readonly showStopPrimary: boolean; + readonly showStopSecondary: boolean; +} + +export function collapsedComposerActions( + input: CollapsedComposerActionsInput, +): CollapsedComposerActions { + const showStopPrimary = input.canStopThread && (!input.hasContent || input.activeThreadBusy); + return { + showStopPrimary, + showStopSecondary: input.canStopThread && input.hasContent && !input.activeThreadBusy, + }; +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7c5575a5d77..d961fef4b1b 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -69,6 +69,7 @@ import { } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; +import { collapsedComposerActions } from "./ThreadComposer.logic"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). @@ -306,7 +307,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer setIsFocused(false); onExpandedChange?.(false); }, [onExpandedChange]); - const showStopAction = props.canStopThread; + const { showStopPrimary: showStopPrimaryAction, showStopSecondary: showStopSecondaryAction } = + collapsedComposerActions({ + canStopThread: props.canStopThread, + hasContent, + activeThreadBusy: props.activeThreadBusy, + }); const sendLabel = props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0 @@ -690,6 +696,33 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } } + let collapsedPrimaryAction: ReactNode = ( + + ); + if (showStopSecondaryAction) { + collapsedPrimaryAction = ( + + + + + ); + } else if (showStopPrimaryAction) { + collapsedPrimaryAction = ( + + ); + } + return ( - {showStopAction ? ( - - ) : ( - - )} + {collapsedPrimaryAction} ) : null} @@ -870,7 +894,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer label={configurationLabel} /> - {showStopAction ? ( + {props.canStopThread && ( - ) : null} + )} ; - readonly activePendingUserInputAnswers: Record | null; + readonly activePendingUserInputAnswers: Record | null; readonly respondingUserInputId: RuntimeRequestId | null; readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 713f4b3acee..ecadf406fe6 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -461,17 +461,22 @@ function ThreadRouteContent( void navigation.navigate("Connections"); }, [navigation]); const handleStopThread = useCallback(() => { - if (!selectedThread || composer.interruptibleRunId === null) { + if (!selectedThread || !composer.canInterruptThread) { return; } return interruptThreadTurn({ environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, - runId: composer.interruptibleRunId, + ...(composer.interruptibleRunId === null ? {} : { runId: composer.interruptibleRunId }), }, }); - }, [composer.interruptibleRunId, interruptThreadTurn, selectedThread]); + }, [ + composer.canInterruptThread, + composer.interruptibleRunId, + interruptThreadTurn, + selectedThread, + ]); const handleOpenTerminal = useCallback( (nextTerminalId?: string | null) => { @@ -762,7 +767,7 @@ function ThreadRouteContent( connectionStateLabel={routeConnectionState} threadSyncStatus={selectedThreadDetailState.status} activeThreadBusy={composer.activeThreadBusy} - canStopThread={composer.interruptibleRunId !== null} + canStopThread={composer.canInterruptThread} environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} threadCwd={selectedThreadCwd} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 4d5f258a285..c15b0c95077 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -10,9 +10,12 @@ import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; import { + buildPendingUserInputAnswers, buildThreadFeed, deriveThreadFeedPresentation, + setPendingUserInputCustomAnswer, threadFeedRunIsUnsettled, + togglePendingUserInputOptionSelection, type ThreadFeedActivity, type ThreadFeedEntry, } from "./threadActivity"; @@ -21,6 +24,53 @@ const threadId = ThreadId.make("thread-1"); const sourceThreadId = ThreadId.make("thread-source"); const runId = RunId.make("run-1"); +const multiSelectQuestion = { + id: "areas", + header: "Areas", + question: "Which areas should this change cover?", + options: [ + { label: "Server", description: "Server" }, + { label: "Mobile", description: "Mobile" }, + ], + multiSelect: true, +} as const; + +describe("pending user input", () => { + it("toggles and submits multiple selected options", () => { + const first = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Server"); + const second = togglePendingUserInputOptionSelection(multiSelectQuestion, first, "Mobile"); + + expect(buildPendingUserInputAnswers([multiSelectQuestion], { areas: second })).toEqual({ + areas: ["Server", "Mobile"], + }); + expect(togglePendingUserInputOptionSelection(multiSelectQuestion, second, "Server")).toEqual({ + customAnswer: "", + selectedOptionLabels: ["Mobile"], + }); + }); + + it("normalizes option labels before toggling a selected value", () => { + const selected = togglePendingUserInputOptionSelection( + multiSelectQuestion, + undefined, + " Server ", + ); + + expect( + togglePendingUserInputOptionSelection(multiSelectQuestion, selected, " Server "), + ).toEqual({ customAnswer: "" }); + }); + + it("clears selected options while a custom answer is active", () => { + expect( + setPendingUserInputCustomAnswer( + { selectedOptionLabels: ["Server", "Mobile"] }, + "No preference", + ), + ).toEqual({ customAnswer: "No preference" }); + }); +}); + function base(id: string, updatedAt: string, ordinal: number) { const timestamp = DateTime.makeUnsafe(updatedAt); return { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index e5729d85776..e840d42e656 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -29,7 +29,7 @@ export type PendingUserInput = ThreadPendingUserInput; const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; export interface PendingUserInputDraftAnswer { - readonly selectedOptionLabel?: string; + readonly selectedOptionLabels?: ReadonlyArray; readonly customAnswer?: string; } @@ -140,12 +140,25 @@ function normalizeDraftAnswer(value: string | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +function normalizeSelectedOptionLabels(value: ReadonlyArray | undefined): string[] { + if (!Array.isArray(value)) return []; + return Array.from( + new Set(value.map((entry) => entry.trim()).filter((entry) => entry.length > 0)), + ); +} + function resolvePendingUserInputAnswer( + question: ThreadUserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, -): string | null { - return ( - normalizeDraftAnswer(draft?.customAnswer) ?? normalizeDraftAnswer(draft?.selectedOptionLabel) - ); +): string | string[] | null { + const customAnswer = normalizeDraftAnswer(draft?.customAnswer); + if (customAnswer) return customAnswer; + + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + if (question.multiSelect) { + return selectedOptionLabels.length > 0 ? selectedOptionLabels : null; + } + return selectedOptionLabels[0] ?? null; } function capitalizePhrase(value: string): string { @@ -654,18 +667,45 @@ export function setPendingUserInputCustomAnswer( draft: PendingUserInputDraftAnswer | undefined, customAnswer: string, ): PendingUserInputDraftAnswer { - const selectedOptionLabel = - customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; - return { customAnswer, ...(selectedOptionLabel ? { selectedOptionLabel } : {}) }; + const selectedOptionLabels = + customAnswer.trim().length > 0 + ? undefined + : normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + return { + customAnswer, + ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), + }; +} + +export function togglePendingUserInputOptionSelection( + question: ThreadUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionLabel: string, +): PendingUserInputDraftAnswer { + const normalizedOptionLabel = optionLabel.trim(); + if (question.multiSelect) { + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + const nextSelectedOptionLabels = selectedOptionLabels.includes(normalizedOptionLabel) + ? selectedOptionLabels.filter((label) => label !== normalizedOptionLabel) + : [...selectedOptionLabels, normalizedOptionLabel]; + return { + customAnswer: "", + ...(nextSelectedOptionLabels.length > 0 + ? { selectedOptionLabels: nextSelectedOptionLabels } + : {}), + }; + } + + return { customAnswer: "", selectedOptionLabels: [normalizedOptionLabel] }; } export function buildPendingUserInputAnswers( questions: ReadonlyArray, draftAnswers: Record, -): Record | null { - const answers: Record = {}; +): Record | null { + const answers: Record = {}; for (const question of questions) { - const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); + const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (!answer) return null; answers[question.id] = answer; } diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index d15646a24f4..6bcf696b7b0 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -10,6 +10,7 @@ import { scopedRequestKey } from "../lib/scopedEntities"; import { buildPendingUserInputAnswers, setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../lib/threadActivity"; import { appAtomRegistry } from "./atom-registry"; @@ -21,15 +22,21 @@ const userInputDraftsByRequestKeyAtom = Atom.make< Record> >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts")); -function setUserInputDraftOption(requestKey: string, questionId: string, label: string): void { +function setUserInputDraftOption( + requestKey: string, + question: Parameters[0], + label: string, +): void { const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { ...current, [requestKey]: { ...current[requestKey], - [questionId]: { - selectedOptionLabel: label, - }, + [question.id]: togglePendingUserInputOptionSelection( + question, + current[requestKey]?.[question.id], + label, + ), }, }); } @@ -94,10 +101,12 @@ export function useSelectedThreadRequests() { return; } + const question = activePendingUserInput?.questions.find((item) => item.id === questionId); + if (!question || activePendingUserInput.requestId !== requestId) return; const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); - setUserInputDraftOption(requestKey, questionId, label); + setUserInputDraftOption(requestKey, question, label); }, - [selectedThreadShell], + [activePendingUserInput, selectedThreadShell], ); const onChangeUserInputCustomAnswer = useCallback( diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 4b199fbb6f2..b3695d1d6d4 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -3,6 +3,8 @@ import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; import { deriveThreadActivityRun, deriveThreadRuntime, + threadRuntimeHasInterruptibleWork, + threadRuntimeHasInterruptibleRun, } from "@t3tools/client-runtime/state/thread-execution"; import { useCallback, useEffect, useMemo } from "react"; @@ -150,7 +152,10 @@ export function useThreadComposerState() { }, [selectedThreadActivityRun, selectedThreadSessionActivity, selectedThreadShell]); const activeThreadBusy = threadRuntimeIsActive(selectedThreadRuntime); - const interruptibleRunId = selectedThreadRuntime?.activeRunId ?? null; + const interruptibleRunId = threadRuntimeHasInterruptibleRun(selectedThreadRuntime) + ? (selectedThreadRuntime?.activeRunId ?? null) + : null; + const canInterruptThread = threadRuntimeHasInterruptibleWork(selectedThreadRuntime); const onSendMessage = useCallback(async () => { if (!selectedThreadShell) { @@ -330,6 +335,7 @@ export function useThreadComposerState() { interactionMode, activeThreadBusy, interruptibleRunId, + canInterruptThread, onChangeDraftMessage, onPickDraftImages, onPasteIntoDraft, diff --git a/apps/server/package.json b/apps/server/package.json index 4613053aeca..31c2fd04bdb 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -35,6 +35,7 @@ "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", + "@opencode-ai/sdk-next": "npm:@opencode-ai/sdk@0.0.0-next-16233", "@pierre/diffs": "catalog:", "effect": "catalog:", "node-pty": "^1.1.0", diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts new file mode 100644 index 00000000000..c5b49e24810 --- /dev/null +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -0,0 +1,52 @@ +import { expect, it } from "@effect/vitest"; +import { RunId, ThreadId, type OrchestrationV2Run } from "@t3tools/contracts"; + +import { type ThreadManagementInterruptResult } from "../orchestration-v2/ThreadManagementService.ts"; +import { orchestratorMcpThreadInterruptResultFor } from "./OrchestratorMcpService.ts"; + +it("maps provider-native interrupt requests to a run-less MCP result", () => { + const threadId = ThreadId.make("thread:mcp-provider-native-interrupt"); + const result: ThreadManagementInterruptResult = { + type: "provider_interrupt_requested", + targets: [], + dispatch: { + sequence: 1, + storedEvents: [], + }, + }; + + expect(orchestratorMcpThreadInterruptResultFor(threadId, result)).toEqual({ + threadId, + runId: null, + status: "interrupt_requested", + }); +}); + +it("preserves the no-active-run MCP result when no provider work was found", () => { + const threadId = ThreadId.make("thread:mcp-no-active-run"); + + expect(orchestratorMcpThreadInterruptResultFor(threadId, { type: "no_active_run" })).toEqual({ + threadId, + runId: null, + status: "no_active_run", + }); +}); + +it("preserves the run id for an ordinary waiting-root interrupt", () => { + const threadId = ThreadId.make("thread:mcp-waiting-root-interrupt"); + const run = { id: RunId.make("run:mcp-waiting-root-interrupt") } as OrchestrationV2Run; + const result: ThreadManagementInterruptResult = { + type: "interrupt_requested", + run, + dispatch: { + sequence: 1, + storedEvents: [], + }, + }; + + expect(orchestratorMcpThreadInterruptResultFor(threadId, result)).toEqual({ + threadId, + runId: run.id, + status: "interrupt_requested", + }); +}); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 0a2705fe6c8..66b31c91669 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -68,6 +68,7 @@ import { latestActiveRun, latestRun, ThreadManagementError, + type ThreadManagementInterruptResult, ThreadManagementService, } from "../orchestration-v2/ThreadManagementService.ts"; import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts"; @@ -177,6 +178,31 @@ function threadManagementFailure(error: ThreadManagementError): OrchestratorMcpF } } +export function orchestratorMcpThreadInterruptResultFor( + threadId: ThreadId, + result: ThreadManagementInterruptResult, +): OrchestratorMcpThreadInterruptResult { + if (result.type === "no_active_run") { + return { + threadId, + runId: null, + status: "no_active_run", + } satisfies OrchestratorMcpThreadInterruptResult; + } + if (result.type === "provider_interrupt_requested") { + return { + threadId, + runId: null, + status: "interrupt_requested", + } satisfies OrchestratorMcpThreadInterruptResult; + } + return { + threadId, + runId: result.run.id, + status: result.type === "already_terminal" ? result.run.status : "interrupt_requested", + } satisfies OrchestratorMcpThreadInterruptResult; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -1534,18 +1560,7 @@ const make = Effect.gen(function* () { ), ), ); - if (result.type === "no_active_run") { - return { - threadId: input.threadId, - runId: null, - status: "no_active_run", - } satisfies OrchestratorMcpThreadInterruptResult; - } - return { - threadId: input.threadId, - runId: result.run.id, - status: result.type === "already_terminal" ? result.run.status : "interrupt_requested", - } satisfies OrchestratorMcpThreadInterruptResult; + return orchestratorMcpThreadInterruptResultFor(input.threadId, result); }), }); }); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts index 9d7294e6356..c4483c2cbb0 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -1,7 +1,12 @@ import { assert, describe, it } from "@effect/vitest"; import { Tool } from "effect/unstable/ai"; -import { CreateThreadsTool, DelegateTaskTool, ScheduleTaskTool } from "./tools.ts"; +import { + CreateThreadsTool, + DelegateTaskTool, + ScheduleTaskTool, + ThreadInterruptTool, +} from "./tools.ts"; describe("orchestrator MCP tool guidance", () => { it("directs subagent requests to delegation instead of ordinary threads", () => { @@ -25,4 +30,14 @@ describe("orchestrator MCP tool guidance", () => { assert.include(ScheduleTaskTool.description ?? "", "STRUCTURED OBJECT"); assert.include(ScheduleTaskTool.description ?? "", "nextRunAt"); }); + + it("documents the no-runId root-run exclusion", () => { + assert.include(ThreadInterruptTool.description ?? "", "waiting"); + assert.include( + ThreadInterruptTool.description ?? "", + "only when the thread shell reports provider-native background work", + ); + assert.include(ThreadInterruptTool.description ?? "", "post-terminal-drain"); + assert.include(ThreadInterruptTool.description ?? "", "no-runId selection"); + }); }); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 8cb692a182d..249d6af0096 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -219,7 +219,7 @@ export const ThreadWaitTool = Tool.make("t3_thread_wait", { export 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. Pressing Stop from inside a provider-native child thread interrupts that child's own active provider turn. Without runId, the newest interruptible root run is selected; a waiting root run is excluded from this no-runId selection only when the thread shell reports provider-native background work, while post-terminal-drain root runs remain excluded. When no root run is interruptible, directly-owned provider-native background children are interrupted and the response has runId null. If no active run or resolvable background child remains, the no-op result has status no_active_run. Terminal runs return without another side effect. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadInterruptInput, success: OrchestratorMcpThreadInterruptResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts index 7066ecb9153..caa4c57fb0f 100644 --- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts @@ -25,6 +25,8 @@ import { NoOpProviderEventLoggers, ProviderEventLoggers, } from "../provider/Layers/ProviderEventLoggers.ts"; +import * as OpenCode2Runtime from "../provider/opencode2Runtime.ts"; +import * as SpawnedProcessReaper from "../provider/SpawnedProcessReaper.ts"; import { OpenCodeRuntimeLive } from "../provider/opencodeRuntime.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -78,6 +80,10 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe NodeServices.layer, FetchHttpClient.layer, OpenCodeRuntimeLive.pipe(Layer.provide(NodeServices.layer)), + OpenCode2Runtime.layer.pipe( + Layer.provide(SpawnedProcessReaper.layer), + Layer.provide(NodeServices.layer), + ), Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), ), ), diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 161b2a6b38b..607ac1b97c1 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -7904,6 +7904,7 @@ describe("AcpAdapterV2", () => { header: "Approve", question: "Approve stale generation 1?", options: [{ label: "yes", description: "Approve" }], + multiSelect: false, }, ], }).pipe(Effect.exit, Effect.forkScoped); @@ -7980,6 +7981,7 @@ describe("AcpAdapterV2", () => { header: "Approve", question: "Approve missing transport?", options: [{ label: "yes", description: "Approve" }], + multiSelect: false, }, ], }).pipe(Effect.exit); @@ -8013,6 +8015,7 @@ describe("AcpAdapterV2", () => { header: "Approve", question: "Approve live generation 2?", options: [{ label: "yes", description: "Approve" }], + multiSelect: false, }, ], }).pipe(Effect.forkScoped); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 6ae3c65078a..779df3ccf86 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -4062,6 +4062,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV header: nonEmptyText(record?.title, `Question ${index + 1}`), question: nonEmptyText(record?.description, params.message), options, + multiSelect: false, }; }, ); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 65abca30d9d..ada5262d14d 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -3013,6 +3013,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi label: nonEmptyText(option.label, `Option ${optionIndex + 1}`), description: nonEmptyText(option.description, option.label), })) ?? [], + multiSelect: false, })); const nodeId = idAllocator.derive.nodeFromProviderItem({ driver: CODEX_PROVIDER, diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index 27f8a603801..80bf7109efc 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -2,8 +2,11 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import type * as EffectAcpSchema from "effect-acp/schema"; +import { XAiAskUserQuestionRequest } from "../../provider/acp/XAiAcpExtension.ts"; +import type * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; import { + type AcpAdapterV2ExtensionContext, AcpProviderCapabilitiesV2, acpCompletedTurnShouldTerminalizeTool, acpPermissionDisposition, @@ -18,6 +21,7 @@ import { import { makeGrokAcpAdapterFlavor, GrokProviderCapabilitiesV2, + registerGrokAcpExtensions, type GrokAdapterV2Options, } from "./GrokAdapterV2.ts"; @@ -208,6 +212,53 @@ describe("acpRootTurnIsIdle", () => { }); describe("GrokAdapterV2 capabilities", () => { + it.effect("forwards native Grok multiselect questions to requestUserInput", () => + Effect.gen(function* () { + type RequestHandler = ( + payload: typeof XAiAskUserQuestionRequest.Type, + ) => Effect.Effect; + let requestHandler: RequestHandler | undefined; + const runtime = { + handleExtNotification: () => Effect.void, + handleExtRequest: (method: string, _payload: unknown, handler: RequestHandler) => + Effect.sync(() => { + if (method === "x.ai/ask_user_question") requestHandler = handler; + }), + } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; + const requests: Array[0]> = []; + + yield* registerGrokAcpExtensions({ + runtime, + applyBackgroundTaskMutation: () => Effect.void, + requestUserInput: (request) => + Effect.sync(() => { + requests.push(request); + return { + acknowledgeNativeResponse: Effect.void, + answers: null, + }; + }), + }); + + assert.isDefined(requestHandler); + yield* requestHandler!({ + sessionId: "session-1", + toolCallId: "tool-call-1", + mode: "default", + questions: [ + { + id: "scope", + question: "Which scopes should Grok use?", + multiSelect: true, + options: [{ label: "Tests" }, { label: "Docs" }], + }, + ], + }); + + assert.isTrue(requests[0]?.questions[0]?.multiSelect); + }), + ); + it("wires hard Stop teardown but soft non-Stop interrupts in the constructor flavor", () => { const flavor = makeGrokAcpAdapterFlavor({ makeRuntime: () => Effect.never, diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts index 163bb325819..b9ecd7a13b7 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts @@ -133,6 +133,7 @@ const registerGrokAskUserQuestionExtensions = ({ header: question.header, question: question.question, options: [...question.options], + multiSelect: question.multiSelect === true, })); return requestUserInput({ nativeItemId: `${identity.sessionId}:xai-question:${identity.toolCallId}`, diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.agentSelection.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.agentSelection.test.ts new file mode 100644 index 00000000000..aefa5a1a406 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.agentSelection.test.ts @@ -0,0 +1,297 @@ +import { assert, it as effectIt } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { + clampOpenCode2Variant, + openCode2InteractionModeForAgent, + openCode2SessionSelectionParameters, + planOpenCode2VariantAlignment, + resolveOpenCode2SessionAgent, + retryEmptyOpenCode2VariantCatalog, +} from "./OpenCode2AdapterV2.ts"; + +describe("openCode2InteractionModeForAgent", () => { + it("maps every native pair event so matching echoes supersede queued reflections", () => { + expect(openCode2InteractionModeForAgent("build")).toBe("default"); + expect(openCode2InteractionModeForAgent("plan")).toBe("plan"); + expect(openCode2InteractionModeForAgent("Release-Captain")).toBeNull(); + }); +}); + +describe("openCode2SessionSelectionParameters", () => { + it("sends the canonical option id when creating or switching a native session", () => { + expect( + openCode2SessionSelectionParameters({ + instanceId: ProviderInstanceId.make("opencode2"), + model: "opencode/glm-5.2", + options: [{ id: "agent", value: "build" }], + }), + ).toEqual({ + model: { id: "glm-5.2", providerID: "opencode" }, + agent: "build", + }); + }); + + it("preserves custom executable agent ids verbatim", () => { + expect( + openCode2SessionSelectionParameters({ + instanceId: ProviderInstanceId.make("opencode2"), + model: "opencode/glm-5.2", + options: [{ id: "agent", value: "Release-Captain" }], + }).agent, + ).toBe("Release-Captain"); + }); + + it("sends the selected variant on the model ref", () => { + expect( + openCode2SessionSelectionParameters({ + instanceId: ProviderInstanceId.make("opencode2"), + model: "opencode/claude-opus-5", + options: [{ id: "variant", value: "high" }], + }).model, + ).toEqual({ id: "claude-opus-5", providerID: "opencode", variant: "high" }); + }); + + // The 2.x server resolves an unset variant to a synthetic id literally named + // "default" that is not in any model's catalog, and an unknown bound variant + // silently swallows the next prompt, so the sentinel must never hit the wire. + it("treats the synthetic default variant as unset", () => { + expect( + openCode2SessionSelectionParameters({ + instanceId: ProviderInstanceId.make("opencode2"), + model: "opencode/claude-opus-5", + options: [{ id: "variant", value: "default" }], + }).model, + ).toEqual({ id: "claude-opus-5", providerID: "opencode" }); + }); + + it("derives the agent from the interaction mode when no option is set", () => { + expect( + openCode2SessionSelectionParameters( + { + instanceId: ProviderInstanceId.make("opencode2"), + model: "opencode/glm-5.2", + }, + "plan", + ).agent, + ).toBe("plan"); + }); +}); + +describe("resolveOpenCode2SessionAgent", () => { + it("keeps a custom agent over the toggle", () => { + expect(resolveOpenCode2SessionAgent("Release-Captain", "plan")).toBe("Release-Captain"); + expect(resolveOpenCode2SessionAgent("Release-Captain", "default")).toBe("Release-Captain"); + expect(resolveOpenCode2SessionAgent("Release-Captain", undefined)).toBe("Release-Captain"); + }); + + // The descriptor's Auto sentinel means "defer to the toggle". + it("treats the auto sentinel as no explicit selection", () => { + expect(resolveOpenCode2SessionAgent("auto", "plan")).toBe("plan"); + expect(resolveOpenCode2SessionAgent("auto", "default")).toBe("build"); + expect(resolveOpenCode2SessionAgent("auto", undefined)).toBeUndefined(); + }); + + // Every pre-toggle thread has a persisted `agent: "build"` selection; it + // must not pin the Build/Plan toggle inert. + it("lets plan mode override a stale build option", () => { + expect(resolveOpenCode2SessionAgent("build", "plan")).toBe("plan"); + }); + + it("honors an explicit plan option in default mode", () => { + expect(resolveOpenCode2SessionAgent("plan", "default")).toBe("plan"); + expect(resolveOpenCode2SessionAgent("plan", "plan")).toBe("plan"); + }); + + it("maps the toggle onto the native pair when no option is set", () => { + expect(resolveOpenCode2SessionAgent(undefined, "default")).toBe("build"); + expect(resolveOpenCode2SessionAgent(undefined, "plan")).toBe("plan"); + expect(resolveOpenCode2SessionAgent("build", "default")).toBe("build"); + }); + + it("drops an agent that the live catalog does not contain", () => { + const buildOnly = new Set(["build"]); + expect(resolveOpenCode2SessionAgent(undefined, "default", buildOnly)).toBe("build"); + expect(resolveOpenCode2SessionAgent(undefined, "plan", buildOnly)).toBeUndefined(); + expect(resolveOpenCode2SessionAgent("Release-Captain", "default", buildOnly)).toBeUndefined(); + }); + + // Subagent child sessions and text generation pass no interaction mode and + // must keep their explicit-option-only behavior. + it("passes the explicit option through when no mode is given", () => { + expect(resolveOpenCode2SessionAgent("build", undefined)).toBe("build"); + expect(resolveOpenCode2SessionAgent("plan", undefined)).toBe("plan"); + expect(resolveOpenCode2SessionAgent(undefined, undefined)).toBeUndefined(); + }); +}); + +describe("clampOpenCode2Variant", () => { + const KNOWN = new Set(["low", "high"]); + + it("passes a catalog variant through", () => { + expect(clampOpenCode2Variant("high", KNOWN)).toEqual({ + variant: "high", + droppedVariant: null, + }); + }); + + it("drops a variant the catalog does not list", () => { + expect(clampOpenCode2Variant("bogus", KNOWN)).toEqual({ + variant: undefined, + droppedVariant: "bogus", + }); + }); + + // Covers a failed catalog fetch, the empty bootstrap catalog, and a model + // missing from the catalog: none can positively validate, so fail closed. + it("drops any variant when the catalog is unavailable", () => { + expect(clampOpenCode2Variant("high", null)).toEqual({ + variant: undefined, + droppedVariant: "high", + }); + }); + + it("leaves an unset variant alone without reporting a drop", () => { + expect(clampOpenCode2Variant(undefined, null)).toEqual({ + variant: undefined, + droppedVariant: null, + }); + }); +}); + +describe("planOpenCode2VariantAlignment", () => { + const MODEL = "opencode/claude-opus-5"; + const KNOWN = new Set(["low", "high"]); + + // Subagent child threads and pre-variant selections carry no variant + // option; that must not reset a variant the native session already has. + it("leaves the bound variant alone when the selection has no opinion", () => { + expect( + planOpenCode2VariantAlignment({ + boundModel: MODEL, + boundVariant: "high", + model: MODEL, + rawVariant: undefined, + knownVariants: KNOWN, + }), + ).toEqual({ switchNeeded: false, variant: undefined, droppedVariant: null }); + }); + + it("switches on a variant-only change between turns", () => { + expect( + planOpenCode2VariantAlignment({ + boundModel: MODEL, + boundVariant: "high", + model: MODEL, + rawVariant: "low", + knownVariants: KNOWN, + }), + ).toEqual({ switchNeeded: true, variant: "low", droppedVariant: null }); + }); + + it("does not switch when the selected variant is already bound", () => { + expect( + planOpenCode2VariantAlignment({ + boundModel: MODEL, + boundVariant: "high", + model: MODEL, + rawVariant: "high", + knownVariants: KNOWN, + }).switchNeeded, + ).toBe(false); + }); + + it("resets a bound variant when the synthetic default is selected", () => { + expect( + planOpenCode2VariantAlignment({ + boundModel: MODEL, + boundVariant: "high", + model: MODEL, + rawVariant: "default", + knownVariants: null, + }), + ).toEqual({ switchNeeded: true, variant: undefined, droppedVariant: null }); + }); + + it("rebinds on a model change even without a variant option", () => { + expect( + planOpenCode2VariantAlignment({ + boundModel: "opencode/glm-5.2", + boundVariant: "high", + model: MODEL, + rawVariant: undefined, + knownVariants: null, + }), + ).toEqual({ switchNeeded: true, variant: undefined, droppedVariant: null }); + }); + + it("clamps an unknown variant to the server default and still switches", () => { + expect( + planOpenCode2VariantAlignment({ + boundModel: MODEL, + boundVariant: "high", + model: MODEL, + rawVariant: "bogus", + knownVariants: KNOWN, + }), + ).toEqual({ switchNeeded: true, variant: undefined, droppedVariant: "bogus" }); + }); +}); + +describe("retryEmptyOpenCode2VariantCatalog", () => { + const POPULATED: ReadonlyMap> = new Map([ + ["opencode/glm-5.2", new Set(["high", "max"])], + ]); + + // The bootstrap window: the server is up but reports an empty catalog, which + // would otherwise make the fail-closed clamp eat a valid first-turn variant. + effectIt.effect("retries an empty bootstrap catalog until it populates", () => + Effect.gen(function* () { + let reads = 0; + const catalog = yield* retryEmptyOpenCode2VariantCatalog( + Effect.sync(() => { + reads += 1; + return reads < 3 ? new Map>() : POPULATED; + }), + { maxAttempts: 5, retryDelayMs: 0 }, + ); + + assert.strictEqual(reads, 3); + assert.strictEqual(catalog?.get("opencode/glm-5.2")?.has("max"), true); + }), + ); + + effectIt.effect("retries a failed fetch and stops after the configured attempts", () => + Effect.gen(function* () { + let reads = 0; + const catalog = yield* retryEmptyOpenCode2VariantCatalog( + Effect.sync(() => { + reads += 1; + return null; + }), + { maxAttempts: 3, retryDelayMs: 0 }, + ); + + assert.strictEqual(reads, 3); + assert.strictEqual(catalog, null); + }), + ); + + effectIt.effect("returns a populated catalog immediately", () => + Effect.gen(function* () { + let reads = 0; + const catalog = yield* retryEmptyOpenCode2VariantCatalog( + Effect.sync(() => { + reads += 1; + return POPULATED; + }), + { maxAttempts: 5, retryDelayMs: 0 }, + ); + + assert.strictEqual(reads, 1); + assert.strictEqual(catalog, POPULATED); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.forms.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.forms.test.ts new file mode 100644 index 00000000000..24eed5bde25 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.forms.test.ts @@ -0,0 +1,141 @@ +import type { FormInfo } from "@opencode-ai/sdk-next/v2"; +import { describe, expect, it } from "vite-plus/test"; + +import { openCode2FormAnswer, openCode2FormQuestions } from "./OpenCode2AdapterV2.ts"; + +// Shape observed live on next-16570: the question tool emits form.created with +// metadata.kind "question" instead of question.v2.asked. +const QUESTION_FORM = { + id: "frm_1", + sessionID: "ses_1", + title: "Questions", + metadata: { kind: "question", tool: { messageID: "msg_1", callID: "call_1" } }, + fields: [ + { + key: "q0", + title: "Quick check", + description: "Is the sky blue?", + type: "string", + options: [ + { value: "yes", label: "Yes, proceed", description: "You are ready to proceed" }, + { value: "no", label: "No", description: "You are not ready yet" }, + ], + custom: true, + }, + ], +} as FormInfo; + +describe("openCode2FormQuestions", () => { + it("maps a question-kind form onto the question request shape", () => { + expect(openCode2FormQuestions(QUESTION_FORM)).toEqual({ + questions: [ + { + header: "Quick check", + question: "Is the sky blue?", + options: [ + { label: "Yes, proceed", description: "You are ready to proceed" }, + { label: "No", description: "You are not ready yet" }, + ], + custom: true, + }, + ], + fieldKeys: ["q0"], + optionValuesByLabel: [{ "Yes, proceed": "yes", No: "no" }], + }); + }); + + it("falls back to the form title and preserves multiselect fields", () => { + const { questions } = openCode2FormQuestions({ + ...QUESTION_FORM, + fields: [ + { + key: "picks", + type: "multiselect", + options: [{ value: "a", label: "A" }], + }, + ], + } as FormInfo); + + expect(questions).toEqual([ + { + header: "Questions", + question: "Questions", + options: [{ label: "A", description: "" }], + multiple: true, + }, + ]); + }); + + it("builds option lookups without inherited property names", () => { + const { optionValuesByLabel } = openCode2FormQuestions({ + ...QUESTION_FORM, + fields: [ + { + key: "q0", + type: "string", + options: [ + { value: "prototype-value", label: "__proto__" }, + { value: "constructor-value", label: "constructor" }, + ], + custom: true, + }, + ], + } as FormInfo); + const valuesByLabel = optionValuesByLabel[0]!; + + expect(Object.getPrototypeOf(valuesByLabel)).toBeNull(); + expect(Object.hasOwn(valuesByLabel, "__proto__")).toBe(true); + expect(valuesByLabel["__proto__"]).toBe("prototype-value"); + expect(valuesByLabel.constructor).toBe("constructor-value"); + expect(valuesByLabel.toString).toBeUndefined(); + }); +}); + +describe("openCode2FormAnswer", () => { + // The UI answers with option labels; the wire wants option values, and + // free-text custom answers pass through untranslated. + it("translates labels to values and keeps custom answers", () => { + expect( + openCode2FormAnswer( + ["q0", "q1"], + [["Yes, proceed"], ["something custom"]], + [{ "Yes, proceed": "yes", No: "no" }, {}], + ), + ).toEqual({ q0: "yes", q1: "something custom" }); + }); + + it("collapses single selections, keeps multi-values, omits unanswered", () => { + expect(openCode2FormAnswer(["q0", "picks", "skipped"], [["Yes"], ["a", "b"]])).toEqual({ + q0: "Yes", + picks: ["a", "b"], + }); + }); + + it("keeps a single multiselect answer as an array", () => { + expect(openCode2FormAnswer(["picks"], [["A"]], [{ A: "a" }], [true])).toEqual({ + picks: ["a"], + }); + }); + + it("preserves hostile field keys and custom answers as own properties", () => { + const answer = openCode2FormAnswer( + ["__proto__", "constructor", "toString", "freeProto"], + [["prototype", "answer"], ["constructor"], ["toString"], ["__proto__"]], + [{}, {}, {}, {}], + [true, false, false, false], + ); + + expect(Object.getPrototypeOf(answer)).toBeNull(); + expect(Object.hasOwn(answer, "__proto__")).toBe(true); + expect(Object.hasOwn(answer, "constructor")).toBe(true); + expect(Object.hasOwn(answer, "toString")).toBe(true); + const expected = { + ["__proto__"]: ["prototype", "answer"], + constructor: "constructor", + freeProto: "__proto__", + toString: "toString", + }; + expect({ ...answer }).toEqual(expected); + expect(JSON.parse(JSON.stringify(answer))).toEqual(expected); + }); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.live.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.live.test.ts new file mode 100644 index 00000000000..059c0ad1d66 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.live.test.ts @@ -0,0 +1,358 @@ +import type { V2Event } from "@opencode-ai/sdk-next/v2"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + OpenCode2Settings, + ProviderInstanceId, + ProviderSessionId, + ThreadId, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +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 Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { describe } from "vite-plus/test"; + +import { ServerConfig } from "../../config.ts"; +import * as OpenCode2Runtime from "../../provider/opencode2Runtime.ts"; +import * as SpawnedProcessReaper from "../../provider/SpawnedProcessReaper.ts"; +import { IdAllocatorV2, layer as idAllocatorLayer } from "../IdAllocator.ts"; +import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; +import { + makeOpenCode2AdapterV2, + OpenCode2ProviderCapabilitiesV2, + unwrapOpenCode2Data, +} from "./OpenCode2AdapterV2.ts"; + +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-opencode2-pending-work-", +}).pipe(Layer.provide(NodeServices.layer)); +const decodeOpenCode2Settings = Schema.decodeUnknownEffect(OpenCode2Settings); +const layer = Layer.mergeAll( + OpenCode2Runtime.layer.pipe( + Layer.provide(SpawnedProcessReaper.layer), + Layer.provide(NodeServices.layer), + ), + idAllocatorLayer, + serverConfigLayer, +); + +type OpenCode2CompactionEvent = Extract< + V2Event, + { + type: + | "session.compaction.admitted" + | "session.compaction.started" + | "session.compaction.delta" + | "session.compaction.ended" + | "session.compaction.failed"; + } +>; + +function isOpenCode2CompactionEvent(event: V2Event): event is OpenCode2CompactionEvent { + return ( + event.type === "session.compaction.admitted" || + event.type === "session.compaction.started" || + event.type === "session.compaction.delta" || + event.type === "session.compaction.ended" || + event.type === "session.compaction.failed" + ); +} + +describe.runIf(process.env.T3_OPENCODE2_LIVE === "1")( + "OpenCode 2 adapter pending work (live)", + () => { + it.effect( + "pins only its running shells and emits a wake when the shell exits", + () => + Effect.scoped( + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const idAllocator = yield* IdAllocatorV2; + const runtime = yield* OpenCode2Runtime.OpenCode2Runtime; + const server = yield* runtime.startOpenCode2ServerProcess({ + binaryPath: "opencode2", + }); + const client = runtime.createOpenCode2SdkClient({ + baseUrl: server.url, + directory: process.cwd(), + serverPassword: server.password, + }); + const instanceId = ProviderInstanceId.make("opencode2-live-test"); + const modelSelection = { + instanceId, + model: "opencode/glm-5.2", + options: [], + }; + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const adapter = makeOpenCode2AdapterV2({ + instanceId, + settings: yield* decodeOpenCode2Settings({ + binaryPath: "opencode2", + serverUrl: server.url, + serverPassword: server.password, + }), + environment: process.env, + runtime, + idAllocator, + serverConfig, + }); + const session = yield* adapter.openSession({ + threadId: ThreadId.make("thread-opencode2-live-test"), + providerSessionId: ProviderSessionId.make("provider-session-opencode2-live-test"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* session.ensureThread({ + threadId: ThreadId.make("thread-opencode2-live-test"), + modelSelection, + runtimePolicy, + }); + const sessionID = providerThread.nativeThreadRef?.nativeId; + assert.isString(sessionID); + assert.isDefined(session.hasPendingBackgroundWork); + assert.isDefined(session.hasPendingBackgroundWorkForThread); + + const created = yield* OpenCode2Runtime.runOpenCode2Sdk("shell.create", () => + client.v2.shell.create({ + location: { directory: process.cwd() }, + command: "sleep 20", + timeout: 30_000, + metadata: { sessionID }, + }), + ); + const shell = yield* unwrapOpenCode2Data<{ readonly id: string }>( + "shell.create", + created, + ); + + assert.isTrue(yield* session.hasPendingBackgroundWork!); + assert.isTrue(yield* session.hasPendingBackgroundWorkForThread!(providerThread)); + const createdWake = yield* session.events.pipe( + Stream.filter((event) => event.type === "provider_thread.updated"), + Stream.runHead, + Effect.timeoutOption("5 seconds"), + ); + assert.isTrue(Option.isSome(createdWake)); + + yield* OpenCode2Runtime.runOpenCode2Sdk("shell.remove", () => + client.v2.shell.remove({ + id: shell.id, + location: { directory: process.cwd() }, + }), + ); + const exitedWake = yield* session.events.pipe( + Stream.filter((event) => event.type === "provider_thread.updated"), + Stream.runHead, + Effect.timeoutOption("5 seconds"), + ); + assert.isTrue(Option.isSome(exitedWake)); + assert.isFalse(yield* session.hasPendingBackgroundWork!); + assert.isFalse(yield* session.hasPendingBackgroundWorkForThread!(providerThread)); + }), + ).pipe(Effect.provide(layer)), + { timeout: 60_000 }, + ); + + it.live( + "observes the native compaction lifecycle used by the adapter projection", + () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* OpenCode2Runtime.OpenCode2Runtime; + const server = yield* runtime.startOpenCode2ServerProcess({ + binaryPath: "opencode2", + }); + const client = runtime.createOpenCode2SdkClient({ + baseUrl: server.url, + directory: process.cwd(), + serverPassword: server.password, + }); + const abortController = new AbortController(); + yield* Effect.addFinalizer(() => Effect.sync(() => abortController.abort())); + const subscription = yield* OpenCode2Runtime.runOpenCode2Sdk("event.subscribe", () => + client.v2.event.subscribe({ signal: abortController.signal }), + ); + const created = yield* OpenCode2Runtime.runOpenCode2Sdk("session.create", () => + client.v2.session.create({ + location: { directory: process.cwd() }, + model: { providerID: "opencode", id: "glm-5.2" }, + }), + ); + const session = yield* unwrapOpenCode2Data<{ readonly id: string }>( + "session.create", + created, + ); + yield* Effect.addFinalizer(() => + OpenCode2Runtime.runOpenCode2Sdk("session.remove", () => + client.v2.session.remove({ sessionID: session.id }), + ).pipe(Effect.ignore), + ); + const eventFiber = yield* Stream.fromAsyncIterable( + subscription.stream, + (cause) => + new OpenCode2Runtime.OpenCode2RuntimeError({ + operation: "event.subscribe", + category: "event-subscription-failed", + cause, + }), + ).pipe( + Stream.filter( + (event): event is OpenCode2CompactionEvent => + isOpenCode2CompactionEvent(event) && event.data.sessionID === session.id, + ), + Stream.takeUntil( + (event) => + event.type === "session.compaction.ended" || + event.type === "session.compaction.failed", + ), + Stream.runCollect, + Effect.forkScoped, + ); + + yield* OpenCode2Runtime.runOpenCode2Sdk("session.prompt", () => + client.v2.session.prompt({ + sessionID: session.id, + text: "Remember this sentence: native compaction fixture context.", + }), + ); + yield* OpenCode2Runtime.runOpenCode2Sdk("session.wait", () => + client.v2.session.wait({ sessionID: session.id }), + ); + const compactionId = `msg_t3_live_compaction_${yield* Clock.currentTimeMillis}`; + yield* OpenCode2Runtime.runOpenCode2Sdk("session.compact", () => + client.v2.session.compact({ + sessionID: session.id, + id: compactionId, + }), + ); + yield* OpenCode2Runtime.runOpenCode2Sdk("session.wait", () => + client.v2.session.wait({ sessionID: session.id }), + ); + + const events = Array.from( + yield* Fiber.join(eventFiber).pipe(Effect.timeout("60 seconds")), + ); + assert.deepEqual( + events + .filter((event) => event.type !== "session.compaction.delta") + .map((event) => event.type), + [ + "session.compaction.admitted", + "session.compaction.started", + "session.compaction.ended", + ], + ); + const ended = events.find((event) => event.type === "session.compaction.ended"); + assert.isDefined(ended); + assert.isAbove(ended.data.text.length, 0); + }), + ).pipe(Effect.provide(layer)), + { timeout: 120_000 }, + ); + + it.live( + "deletes a detached native session idempotently", + () => + Effect.scoped( + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const idAllocator = yield* IdAllocatorV2; + const runtime = yield* OpenCode2Runtime.OpenCode2Runtime; + const server = yield* runtime.startOpenCode2ServerProcess({ + binaryPath: "opencode2", + }); + const client = runtime.createOpenCode2SdkClient({ + baseUrl: server.url, + directory: process.cwd(), + serverPassword: server.password, + }); + const instanceId = ProviderInstanceId.make("opencode2-live-delete-test"); + const providerSessionId = ProviderSessionId.make( + "provider-session-opencode2-live-delete-test", + ); + const adapter = makeOpenCode2AdapterV2({ + instanceId, + settings: yield* decodeOpenCode2Settings({ + binaryPath: "opencode2", + serverUrl: server.url, + serverPassword: server.password, + }), + environment: process.env, + runtime, + idAllocator, + serverConfig, + }); + const deleteDetachedThread = adapter.deleteDetachedThread; + assert.isDefined(deleteDetachedThread); + + const created = yield* OpenCode2Runtime.runOpenCode2Sdk("session.create", () => + client.v2.session.create({ + location: { directory: process.cwd() }, + model: { providerID: "opencode", id: "glm-5.2" }, + }), + ); + const nativeSession = yield* unwrapOpenCode2Data<{ readonly id: string }>( + "session.create", + created, + ); + const now = yield* DateTime.now; + const providerSession = { + id: providerSessionId, + driver: adapter.driver, + providerInstanceId: instanceId, + status: "stopped" as const, + cwd: process.cwd(), + model: "opencode/glm-5.2", + capabilities: OpenCode2ProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }; + const providerThread = { + id: idAllocator.derive.providerThread({ + driver: adapter.driver, + nativeThreadId: nativeSession.id, + }), + driver: adapter.driver, + providerInstanceId: instanceId, + providerSessionId, + appThreadId: ThreadId.make("thread-opencode2-live-delete-test"), + ownerNodeId: null, + nativeThreadRef: { + driver: adapter.driver, + nativeId: nativeSession.id, + strength: "strong" as const, + }, + nativeConversationHeadRef: null, + status: "idle" as const, + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + pendingBackgroundTasks: [], + createdAt: now, + updatedAt: now, + }; + + yield* deleteDetachedThread({ providerSession, providerThread }); + yield* deleteDetachedThread({ providerSession, providerThread }); + + const missing = yield* OpenCode2Runtime.runOpenCode2Sdk("session.get", () => + client.v2.session.get({ sessionID: nativeSession.id }, { throwOnError: false }), + ); + assert.equal(missing.response.status, 404); + }), + ).pipe(Effect.provide(layer)), + { timeout: 60_000 }, + ); + }, +); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts new file mode 100644 index 00000000000..4919bb30260 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -0,0 +1,993 @@ +import type { SessionPendingInfo, ShellInfoV2, V2Event } from "@opencode-ai/sdk-next/v2"; +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { describe } from "vite-plus/test"; + +import { + openCode2AutoPermissionReply, + openCode2ChildTurnItemOrdinals, + openCode2EnvironmentWithPermission, + openCode2EnvironmentWithT3Mcp, + openCode2EventEndsExecution, + openCode2ForkParameters, + openCode2InterruptedThreadDisposition, + openCode2IsCancelledPostSettleWake, + openCode2IsPostSettleWakeAdmission, + openCode2PendingWorkForSession, + openCode2PermissionAutoReply, + openCode2PermissionAutoReplyForSession, + openCode2QuestionId, + openCode2SessionSelectionParameters, + openCode2SessionErrorMessage, + openCode2SessionErrorStatus, + openCode2SessionErrorTargetSessionIds, + openCode2ShouldSettleTurn, + openCode2ToolNeedsTerminalOverride, + normalizeOpenCode2PermissionEvent, + OPENCODE2_PROMOTED_INPUT_ID_LIMIT, + OPENCODE2_RETIRED_SUPPRESS_WAKE_LIMIT, + pruneOpenCode2PromotedInputIds, + pruneOpenCode2RetiredSuppressWakes, + rememberOpenCode2SessionPermission, + removeOpenCode2Session, + unwrapOpenCode2Data, +} from "./OpenCode2AdapterV2.ts"; + +const v2Event = (event: unknown) => event as V2Event; + +const t3McpSession = { + environmentId: EnvironmentId.make("environment:test"), + threadId: ThreadId.make("thread:test"), + providerSessionId: "provider-session:test", + providerInstanceId: ProviderInstanceId.make("opencode2"), + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer test-token", +}; +const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString); +const encodeJson = Schema.encodeSync(Schema.UnknownFromJsonString); + +describe("unwrapOpenCode2Data", () => { + it.effect("reads through both envelopes", () => + Effect.gen(function* () { + const payload = yield* unwrapOpenCode2Data("session.create", { + data: { data: { id: "ses_1" } }, + }); + assert.deepStrictEqual(payload, { + id: "ses_1", + }); + }), + ); + + // The failure mode this guards is silent: reading one layer yields the + // envelope, which looks like a valid object and fails much later. + it.effect("fails through the typed channel for an outer-only envelope", () => + Effect.gen(function* () { + const error = yield* unwrapOpenCode2Data("session.create", { data: {} }).pipe(Effect.flip); + assert.strictEqual(error.operation, "session.create"); + assert.strictEqual(error.category, "missing-response-payload"); + }), + ); + + it.effect("fails through the typed channel for a missing payload", () => + Effect.gen(function* () { + const error = yield* unwrapOpenCode2Data("session.get", {}).pipe(Effect.flip); + assert.strictEqual(error.operation, "session.get"); + assert.strictEqual(error.category, "missing-response-payload"); + }), + ); +}); + +describe("OpenCode 2 post-settle wake classification", () => { + const syntheticAdmission = v2Event({ + type: "session.input.admitted", + data: { + sessionID: "ses_root", + inputID: "input_wake", + input: { + type: "synthetic", + data: { text: 'child completed' }, + delivery: "queue", + }, + }, + }); + + it("classifies provider completion admissions for input-aware ownership", () => { + assert.isTrue( + openCode2IsPostSettleWakeAdmission(syntheticAdmission, { + isChildSession: false, + }), + ); + assert.isTrue( + openCode2IsPostSettleWakeAdmission(syntheticAdmission, { + isChildSession: false, + }), + ); + assert.isFalse( + openCode2IsPostSettleWakeAdmission(syntheticAdmission, { + isChildSession: true, + }), + ); + assert.isFalse( + openCode2IsPostSettleWakeAdmission( + v2Event({ + type: "session.input.admitted", + data: { + sessionID: "ses_root", + inputID: "input_user", + input: { type: "user", data: { text: "hello" }, delivery: "queue" }, + }, + }), + { + isChildSession: false, + }, + ), + ); + }); + + it("keeps in-turn synthetic control instructions on their owning execution", () => { + assert.isFalse( + openCode2IsPostSettleWakeAdmission( + v2Event({ + type: "session.input.admitted", + data: { + sessionID: "ses_root", + inputID: "input_background_instruction", + input: { + type: "synthetic", + data: { + text: "User requested that active blocking work be moved to the background.", + }, + delivery: "steer", + }, + }, + }), + { isChildSession: false }, + ), + ); + }); + + it("accepts observed provider metadata when completion text is not wrapped", () => { + assert.isTrue( + openCode2IsPostSettleWakeAdmission( + v2Event({ + type: "session.input.admitted", + data: { + sessionID: "ses_root", + inputID: "input_shell_wake", + input: { + type: "synthetic", + data: { + text: "shell completed", + metadata: { source: "shell", state: "completed" }, + }, + delivery: "steer", + }, + }, + }), + { isChildSession: false }, + ), + ); + }); + + it("classifies a synthetic wake beside an allocated user execution", () => { + assert.isTrue( + openCode2IsPostSettleWakeAdmission(syntheticAdmission, { + isChildSession: false, + }), + ); + assert.isTrue( + openCode2IsPostSettleWakeAdmission(syntheticAdmission, { + isChildSession: false, + }), + ); + }); + + it("defers cancelled wake ownership until input promotion identifies the execution", () => { + const cancelledAdmission = v2Event({ + type: "session.input.admitted", + data: { + sessionID: "ses_root", + inputID: "input_cancelled", + input: { + type: "synthetic", + data: { text: 'cancelled continuation' }, + delivery: "queue", + }, + }, + }); + + assert.isTrue( + openCode2IsPostSettleWakeAdmission(cancelledAdmission, { + isChildSession: false, + }), + ); + assert.isTrue( + openCode2IsPostSettleWakeAdmission(syntheticAdmission, { + isChildSession: false, + }), + ); + }); + + it("isolates cancellation synthetic inputs without dropping the wake boundary", () => { + for (const state of ["cancelled", "interrupted"] as const) { + const event = v2Event({ + type: "session.input.admitted", + data: { + sessionID: "ses_root", + inputID: `input_${state}`, + input: { + type: "synthetic", + data: { text: `partial output` }, + delivery: "queue", + }, + }, + }); + assert.isTrue(openCode2IsCancelledPostSettleWake(event)); + assert.isTrue( + openCode2IsPostSettleWakeAdmission(event, { + isChildSession: false, + }), + ); + } + assert.isFalse(openCode2IsCancelledPostSettleWake(syntheticAdmission)); + }); + + it("accepts the observed state marker with flexible tag attributes", () => { + for (const text of [ + ` \n\tpartial output`, + `partial output`, + `partial output`, + `partial output`, + ]) { + assert.isTrue( + openCode2IsCancelledPostSettleWake( + v2Event({ + type: "session.input.admitted", + data: { + sessionID: "ses_root", + inputID: "input_cancelled", + input: { + type: "synthetic", + data: { text }, + delivery: "queue", + }, + }, + }), + ), + ); + } + assert.isFalse( + openCode2IsCancelledPostSettleWake( + v2Event({ + type: "session.input.admitted", + data: { + sessionID: "ses_root", + inputID: "input_status", + input: { + type: "synthetic", + data: { text: `partial output` }, + delivery: "queue", + }, + }, + }), + ), + ); + }); + + it("requires a top-level cancellation marker at the start of synthetic text", () => { + for (const text of [ + 'completed output', + 'quoted text: "nested output"', + 'nested output', + ]) { + assert.isFalse( + openCode2IsCancelledPostSettleWake( + v2Event({ + type: "session.input.admitted", + data: { + sessionID: "ses_root", + inputID: "input_not_cancelled", + input: { + type: "synthetic", + data: { text }, + delivery: "queue", + }, + }, + }), + ), + ); + } + }); + + it("suppresses an empty interrupted background-shell wake", () => { + const interruptedShellAdmission = v2Event({ + type: "session.input.admitted", + data: { + sessionID: "ses_root", + inputID: "input_interrupted_shell", + input: { + type: "synthetic", + data: { + text: '\n\n', + metadata: { source: "shell", state: "error" }, + }, + delivery: "steer", + }, + }, + }); + assert.isTrue(openCode2IsCancelledPostSettleWake(interruptedShellAdmission)); + }); + + it("keeps a background-shell error that carries output visible", () => { + assert.isFalse( + openCode2IsCancelledPostSettleWake( + v2Event({ + type: "session.input.admitted", + data: { + sessionID: "ses_root", + inputID: "input_failed_shell", + input: { + type: "synthetic", + data: { + text: 'command failed', + metadata: { source: "shell", state: "error" }, + }, + delivery: "steer", + }, + }, + }), + ), + ); + }); + + it("leaves a child synthetic admission eligible for child-turn creation", () => { + assert.isTrue( + openCode2IsCancelledPostSettleWake( + v2Event({ + type: "session.input.admitted", + data: { + sessionID: "ses_child", + inputID: "input_child_cancelled", + input: { + type: "synthetic", + data: { text: `partial output` }, + delivery: "queue", + }, + }, + }), + ), + ); + assert.isFalse( + openCode2IsPostSettleWakeAdmission( + v2Event({ + type: "session.input.admitted", + data: { + sessionID: "ses_child", + inputID: "input_child_cancelled", + input: { + type: "synthetic", + data: { text: `partial output` }, + delivery: "queue", + }, + }, + }), + { + isChildSession: true, + }, + ), + ); + }); + + it("closes a buffered wake only on execution terminal or idle", () => { + assert.isFalse( + openCode2EventEndsExecution( + v2Event({ type: "session.execution.started", data: { sessionID: "ses_root" } }), + ), + ); + for (const type of [ + "session.execution.succeeded", + "session.execution.failed", + "session.execution.interrupted", + "session.idle", + ] as const) { + assert.isTrue( + openCode2EventEndsExecution(v2Event({ type, data: { sessionID: "ses_root" } })), + ); + } + }); +}); + +describe("OpenCode 2 wake evidence bounds", () => { + it("evicts the oldest retired suppression evidence in insertion order", () => { + const wakes = new Map(); + for (let index = 0; index < OPENCODE2_RETIRED_SUPPRESS_WAKE_LIMIT + 2; index += 1) { + wakes.set(`input_retired_${index}`, {}); + } + + pruneOpenCode2RetiredSuppressWakes(wakes); + + assert.equal(wakes.size, OPENCODE2_RETIRED_SUPPRESS_WAKE_LIMIT); + assert.isFalse(wakes.has("input_retired_0")); + assert.isFalse(wakes.has("input_retired_1")); + assert.isTrue(wakes.has(`input_retired_${OPENCODE2_RETIRED_SUPPRESS_WAKE_LIMIT + 1}`)); + }); + + it("keeps recent unclaimed promotion evidence for late admissions", () => { + const inputIds = new Set( + Array.from( + { length: OPENCODE2_PROMOTED_INPUT_ID_LIMIT + 1 }, + (_, index) => `input_promoted_${index}`, + ), + ); + + pruneOpenCode2PromotedInputIds(inputIds); + + assert.equal(inputIds.size, OPENCODE2_PROMOTED_INPUT_ID_LIMIT); + assert.isFalse(inputIds.has("input_promoted_0")); + assert.isTrue(inputIds.has(`input_promoted_${OPENCODE2_PROMOTED_INPUT_ID_LIMIT}`)); + }); +}); + +describe("OpenCode 2 session selection", () => { + it("round-trips a provider model whose id contains a slash", () => { + assert.deepStrictEqual( + openCode2SessionSelectionParameters({ + instanceId: ProviderInstanceId.make("opencode2"), + model: "openrouter/qwen/qwen3-coder", + }), + { + model: { + providerID: "openrouter", + id: "qwen/qwen3-coder", + }, + }, + ); + }); +}); + +describe("removeOpenCode2Session", () => { + it.effect("treats an already-missing native session as deleted", () => + removeOpenCode2Session( + "ses_missing", + Effect.succeed({ + data: undefined, + error: { name: "SessionNotFoundError" }, + response: { status: 404 }, + }), + ), + ); + + it.effect("retains non-idempotent native deletion failures", () => + Effect.gen(function* () { + const failure = yield* removeOpenCode2Session( + "ses_broken", + Effect.succeed({ + data: undefined, + error: { name: "InternalServerError" }, + response: { status: 500 }, + }), + ).pipe(Effect.flip); + + assert.strictEqual(failure.operation, "session.remove"); + assert.strictEqual(failure.category, "session-remove-failed"); + assert.include(failure.message, "session-remove-failed"); + assert.notInclude(failure.message, "InternalServerError"); + }), + ); +}); + +describe("openCode2QuestionId", () => { + it("slugs the header so answers keyed by id resolve", () => { + assert.strictEqual(openCode2QuestionId(0, "Pick a Branch!"), "question-0-pick-a-branch"); + }); + + it("falls back to the index when the header carries no usable characters", () => { + assert.strictEqual(openCode2QuestionId(2, " ??? "), "question-2"); + }); +}); + +describe("openCode2ForkParameters", () => { + it("maps a boundary message onto the required before union member", () => { + assert.deepStrictEqual(openCode2ForkParameters("ses_123", "msg_456"), { + sessionID: "ses_123", + $body_boundary: { type: "before", messageID: "msg_456" }, + }); + }); + + it("maps a whole-head fork onto the through union member", () => { + assert.deepStrictEqual(openCode2ForkParameters("ses_123", undefined), { + sessionID: "ses_123", + $body_boundary: { type: "through" }, + }); + }); +}); + +describe("openCode2EnvironmentWithT3Mcp", () => { + it.effect("merges a per-thread server into process-local inline config", () => + Effect.gen(function* () { + const environment = { + CUSTOM_ENV: "preserved", + OPENCODE_CONFIG_CONTENT: encodeJson({ + agent: { build: { mode: "primary" } }, + mcp: { + existing: { + type: "local", + command: ["existing-mcp"], + }, + }, + }), + }; + const result = yield* openCode2EnvironmentWithT3Mcp(environment, t3McpSession); + const resultEnvironment: NodeJS.ProcessEnv = result; + + assert.strictEqual(resultEnvironment.CUSTOM_ENV, "preserved"); + assert.deepStrictEqual(decodeJson(result.OPENCODE_CONFIG_CONTENT ?? ""), { + agent: { build: { mode: "primary" } }, + mcp: { + existing: { + type: "local", + command: ["existing-mcp"], + }, + "t3-code": { + type: "remote", + url: t3McpSession.endpoint, + headers: { Authorization: t3McpSession.authorizationHeader }, + oauth: false, + }, + }, + }); + assert.notStrictEqual(result, environment); + }), + ); + + it.effect("rejects inline config whose MCP field cannot be merged safely", () => + Effect.gen(function* () { + const failure = yield* openCode2EnvironmentWithT3Mcp( + { OPENCODE_CONFIG_CONTENT: encodeJson({ mcp: false }) }, + t3McpSession, + ).pipe(Effect.flip); + + assert.isDefined(failure); + }), + ); + + it.effect("creates inline config when content is absent or empty", () => + Effect.gen(function* () { + const environments: Array = [{}, { OPENCODE_CONFIG_CONTENT: "" }]; + for (const environment of environments) { + const result = yield* openCode2EnvironmentWithT3Mcp(environment, t3McpSession); + assert.deepStrictEqual(decodeJson(result.OPENCODE_CONFIG_CONTENT ?? ""), { + mcp: { + "t3-code": { + type: "remote", + url: t3McpSession.endpoint, + headers: { Authorization: t3McpSession.authorizationHeader }, + oauth: false, + }, + }, + }); + } + }), + ); +}); + +describe("openCode2EnvironmentWithPermission", () => { + const policy = (overrides: Record) => + ({ + cwd: "/tmp", + interactionMode: "default", + runtimeMode: "default", + ...overrides, + }) as never; + + it.effect("injects allow while preserving inline MCP config for implicit full access", () => + Effect.gen(function* () { + const environment = { + OPENCODE_CONFIG_CONTENT: encodeJson({ + mcp: { + existing: { + type: "local", + command: ["existing-mcp"], + }, + }, + }), + }; + const result = yield* openCode2EnvironmentWithPermission( + environment, + policy({ runtimeMode: "full-access" }), + ); + + assert.deepStrictEqual(decodeJson(result.OPENCODE_CONFIG_CONTENT ?? ""), { + permission: "allow", + mcp: { + existing: { + type: "local", + command: ["existing-mcp"], + }, + }, + }); + }), + ); + + it.effect("does not inject allow when the policy requires approval", () => + Effect.gen(function* () { + const environment = { + OPENCODE_CONFIG_CONTENT: encodeJson({ + mcp: { + existing: { + type: "local", + command: ["existing-mcp"], + }, + }, + }), + }; + const result = yield* openCode2EnvironmentWithPermission( + environment, + policy({ approvalPolicy: "on-request", runtimeMode: "full-access" }), + ); + + assert.strictEqual(result, environment); + assert.deepStrictEqual(decodeJson(result.OPENCODE_CONFIG_CONTENT ?? ""), { + mcp: { + existing: { + type: "local", + command: ["existing-mcp"], + }, + }, + }); + }), + ); + + it.effect("injects allow when content is absent or empty", () => + Effect.gen(function* () { + const environments: Array = [{}, { OPENCODE_CONFIG_CONTENT: "" }]; + for (const environment of environments) { + const result = yield* openCode2EnvironmentWithPermission( + environment, + policy({ runtimeMode: "full-access" }), + ); + assert.deepStrictEqual(decodeJson(result.OPENCODE_CONFIG_CONTENT ?? ""), { + permission: "allow", + }); + } + }), + ); +}); + +describe("openCode2AutoPermissionReply", () => { + const policy = (overrides: Record) => + ({ + cwd: "/tmp", + runtimeMode: "default", + interactionMode: "default", + ...overrides, + }) as never; + const reply = ( + overrides: Record, + action: string, + resources: ReadonlyArray = ["*"], + ) => openCode2AutoPermissionReply(policy(overrides), { action, resources }); + + it("approves only the current request in full-access mode", () => { + assert.strictEqual(reply({ runtimeMode: "full-access" }, "bash"), "once"); + }); + + it("does not turn approval never into implicit full access", () => { + assert.strictEqual(reply({ runtimeMode: "auto", approvalPolicy: "never" }, "bash"), "reject"); + assert.strictEqual(reply({ runtimeMode: "auto", approvalPolicy: "never" }, "read"), "once"); + }); + + it("surfaces the request when an approval policy asks for one", () => { + assert.strictEqual( + reply({ runtimeMode: "full-access", approvalPolicy: "always" }, "bash"), + null, + ); + }); + + // A structured approval policy is a request for interactive review, so + // full-access must not silently override it. + it("surfaces the request for a structured approval policy", () => { + assert.strictEqual( + reply({ runtimeMode: "full-access", approvalPolicy: { type: "onRequest" } }, "bash"), + null, + ); + }); + + it("auto-accepts edits but still asks for shell access", () => { + assert.strictEqual(reply({ runtimeMode: "auto-accept-edits" }, "edit"), "once"); + assert.strictEqual(reply({ runtimeMode: "auto-accept-edits" }, "bash"), null); + }); + + it("enforces workspace-write and network policy without native persistent grants", () => { + const sandboxPolicy = { + type: "workspaceWrite", + networkAccess: true, + writableRoots: ["/workspace/shared"], + }; + const overrides = { + runtimeMode: "auto", + approvalPolicy: "never", + sandboxPolicy, + }; + assert.strictEqual(reply(overrides, "edit"), "once"); + assert.strictEqual(reply(overrides, "bash"), "reject"); + assert.strictEqual(reply(overrides, "websearch"), "once"); + assert.strictEqual( + reply(overrides, "external_directory", ["/workspace/shared/file.txt"]), + "once", + ); + assert.strictEqual(reply(overrides, "external_directory", ["/outside/file.txt"]), "reject"); + }); + + it("does not let a remembered session grant override a later policy denial", () => { + assert.strictEqual( + openCode2PermissionAutoReply( + policy({ runtimeMode: "auto", approvalPolicy: "never" }), + [{ action: "bash", resources: ["*"] }], + { action: "bash", resources: ["*"] }, + ), + "reject", + ); + }); + + it("uses a remembered session grant when policy still requires approval", () => { + assert.strictEqual( + openCode2PermissionAutoReply( + policy({ runtimeMode: "default" }), + [{ action: "bash", resources: ["/workspace/*"] }], + { action: "bash", resources: ["/workspace/file.txt"] }, + ), + "once", + ); + }); + + it("combines remembered grants per resource in a multi-resource request", () => { + assert.strictEqual( + openCode2PermissionAutoReply( + policy({ runtimeMode: "default" }), + [ + { action: "bash", resources: ["/workspace/first/*"] }, + { action: "bash", resources: ["/workspace/second/*"] }, + ], + { + action: "bash", + resources: ["/workspace/first/file.txt", "/workspace/second/file.txt"], + }, + ), + "once", + ); + }); +}); + +describe("normalizeOpenCode2PermissionEvent", () => { + it("treats a missing legacy patterns list as a wildcard request", () => { + assert.deepStrictEqual( + normalizeOpenCode2PermissionEvent("legacy", { + id: "permission-1", + sessionID: "session-1", + permission: "grep", + metadata: {}, + always: [], + }), + { + action: "grep", + resources: [], + save: [], + }, + ); + }); + + it("accepts preview aliases and singular patterns", () => { + assert.deepStrictEqual( + normalizeOpenCode2PermissionEvent("v2", { + permission: "external_directory", + pattern: "/workspace/file.txt", + always: ["/workspace/*"], + }), + { + action: "external_directory", + resources: ["/workspace/file.txt"], + save: ["/workspace/*"], + }, + ); + }); +}); + +describe("OpenCode 2 remembered session permissions", () => { + const runtimePolicy = { + cwd: "/tmp", + interactionMode: "default", + runtimeMode: "default", + } as never; + + it("scopes remembered grants to their native session", () => { + const permissions = new Map(); + rememberOpenCode2SessionPermission(permissions, "ses_child", { + action: "bash", + resources: ["/workspace/*"], + save: [], + }); + const request = { action: "bash", resources: ["/workspace/file.txt"] }; + + assert.strictEqual( + openCode2PermissionAutoReplyForSession(runtimePolicy, permissions, "ses_child", request), + "once", + ); + assert.isNull( + openCode2PermissionAutoReplyForSession(runtimePolicy, permissions, "ses_sibling", request), + ); + }); + + it("remembers a resource-less grant as a wildcard", () => { + const permissions = new Map(); + rememberOpenCode2SessionPermission(permissions, "ses_root", { + action: "grep", + resources: [], + save: [], + }); + + assert.strictEqual( + openCode2PermissionAutoReplyForSession(runtimePolicy, permissions, "ses_root", { + action: "grep", + resources: ["/workspace/file.txt"], + }), + "once", + ); + }); +}); + +describe("OpenCode 2 child item ordinals", () => { + it("reserves a distinct item block for every child turn", () => { + assert.deepStrictEqual(openCode2ChildTurnItemOrdinals(1), { user: 100, next: 101 }); + assert.deepStrictEqual(openCode2ChildTurnItemOrdinals(2), { user: 200, next: 201 }); + }); +}); + +describe("openCode2PendingWorkForSession", () => { + const sessionID = "ses_target"; + const pending = (owner: string): SessionPendingInfo => ({ + admittedSeq: 1, + id: "pending-1", + sessionID: owner, + timeCreated: 1, + type: "compaction", + }); + const shell = (owner: string, status: ShellInfoV2["status"]): ShellInfoV2 => ({ + id: "shell-1", + status, + command: "sleep 20", + cwd: "/workspace", + shell: "/bin/bash", + file: "/workspace/shell.out", + metadata: { sessionID: owner }, + time: { started: 1 }, + }); + + it.effect("pins the thread for its durable pending input without listing shells", () => + Effect.gen(function* () { + let listedShells = false; + const result = yield* openCode2PendingWorkForSession({ + sessionID, + pending: Effect.succeed([pending(sessionID)]), + shells: Effect.sync(() => { + listedShells = true; + return []; + }), + }); + + assert.isTrue(result); + assert.isFalse(listedShells); + }), + ); + + it.effect("pins only running shells owned by the same native session", () => + Effect.gen(function* () { + assert.isTrue( + yield* openCode2PendingWorkForSession({ + sessionID, + pending: Effect.succeed([]), + shells: Effect.succeed([shell(sessionID, "running")]), + }), + ); + assert.isFalse( + yield* openCode2PendingWorkForSession({ + sessionID, + pending: Effect.succeed([pending("ses_sibling")]), + shells: Effect.succeed([shell("ses_sibling", "running"), shell(sessionID, "exited")]), + }), + ); + }), + ); +}); + +describe("openCode2ToolNeedsTerminalOverride", () => { + const part = (status: "pending" | "running" | "completed" | "error", errorMessage?: string) => ({ + status, + errorMessage, + }); + + it("terminalizes tools that have no native terminal state", () => { + assert.isTrue(openCode2ToolNeedsTerminalOverride(part("pending"), "failed")); + assert.isTrue(openCode2ToolNeedsTerminalOverride(part("running"), "interrupted")); + }); + + it("restamps only the provider's interrupt-specific tool failure", () => { + assert.isTrue( + openCode2ToolNeedsTerminalOverride( + part("error", "Tool execution interrupted"), + "interrupted", + ), + ); + assert.isFalse( + openCode2ToolNeedsTerminalOverride(part("error", "command failed"), "interrupted"), + ); + assert.isFalse( + openCode2ToolNeedsTerminalOverride(part("error", "Tool execution interrupted"), "failed"), + ); + }); + + it("preserves completed tools", () => { + assert.isFalse(openCode2ToolNeedsTerminalOverride(part("completed"), "interrupted")); + }); +}); + +describe("OpenCode 2 session errors", () => { + it("fans an unscoped error out to every active native session", () => { + assert.deepStrictEqual( + openCode2SessionErrorTargetSessionIds(undefined, ["ses_first", "ses_second"]), + ["ses_first", "ses_second"], + ); + assert.deepStrictEqual( + openCode2SessionErrorTargetSessionIds("ses_second", ["ses_first", "ses_second"]), + ["ses_second"], + ); + assert.deepStrictEqual( + openCode2SessionErrorTargetSessionIds("ses_missing", ["ses_first", "ses_second"]), + [], + ); + }); + + it("normalizes provider abort errors without poisoning the provider session", () => { + const error = { + sessionID: "ses_target", + error: { + name: "MessageAbortedError", + data: { message: "The user aborted the request." }, + }, + } as const; + + assert.strictEqual(openCode2SessionErrorMessage(error), "The user aborted the request."); + assert.strictEqual(openCode2SessionErrorStatus(error, false), "interrupted"); + }); + + it("preserves ordinary provider failures", () => { + const error = { + error: { + name: "UnknownError", + data: { message: "Provider exploded." }, + }, + } as const; + + assert.strictEqual(openCode2SessionErrorMessage(error), "Provider exploded."); + assert.strictEqual(openCode2SessionErrorStatus(error, false), "failed"); + assert.strictEqual(openCode2SessionErrorStatus(error, true), "interrupted"); + }); + + it("breaks a native thread only when the provider shuts down", () => { + assert.strictEqual(openCode2InterruptedThreadDisposition("user"), "reusable"); + assert.strictEqual(openCode2InterruptedThreadDisposition("superseded"), "reusable"); + assert.strictEqual(openCode2InterruptedThreadDisposition("shutdown"), "broken"); + }); + + it("uses idle only before the authoritative execution lifecycle starts", () => { + assert.isTrue(openCode2ShouldSettleTurn("idle", false)); + assert.isFalse(openCode2ShouldSettleTurn("execution-terminal", false)); + assert.isFalse(openCode2ShouldSettleTurn("execution-interrupted", false)); + assert.isTrue(openCode2ShouldSettleTurn("execution-interrupted", false, true)); + assert.isFalse(openCode2ShouldSettleTurn("idle", true)); + assert.isTrue(openCode2ShouldSettleTurn("execution-terminal", true)); + assert.isTrue(openCode2ShouldSettleTurn("execution-interrupted", true)); + }); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.test.ts new file mode 100644 index 00000000000..5366fa57d07 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.test.ts @@ -0,0 +1,360 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { OPENCODE2_PROVIDER } from "./OpenCode2AdapterV2.ts"; +import { + OPENCODE2_SDK_REPLAY_PROTOCOL, + OpenCode2ReplayController, + OpenCode2ReplayMismatchError, + makeReplayClient, + type OpenCode2SdkReplayTranscript, +} from "./OpenCode2AdapterV2.testkit.ts"; + +function transcript(entries: ReadonlyArray): OpenCode2SdkReplayTranscript { + return { + provider: OPENCODE2_PROVIDER, + protocol: OPENCODE2_SDK_REPLAY_PROTOCOL, + version: "test", + scenario: "replay-controller", + entries, + } as unknown as OpenCode2SdkReplayTranscript; +} + +describe("OpenCode2AdapterV2 replay testkit", () => { + it.effect("fails deterministically when an SDK response is exhausted", () => + Effect.gen(function* () { + const controller = new OpenCode2ReplayController(transcript([])); + const isReplayMismatchError = Schema.is(OpenCode2ReplayMismatchError); + const exit = yield* Effect.exit( + Effect.tryPromise({ + try: () => controller.response("session.get"), + catch: (cause) => + isReplayMismatchError(cause) + ? cause + : new OpenCode2ReplayMismatchError({ + scenario: "replay-controller", + cursor: 0, + expected: { type: "sdk.response", operation: "session.get" }, + actual: cause, + }), + }), + ); + + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.isTrue(isReplayMismatchError(Cause.squash(exit.cause))); + } + }), + ); + + it.effect("claims distinct delayed responses for concurrent SDK requests", () => + Effect.gen(function* () { + const controller = new OpenCode2ReplayController( + transcript([ + { + type: "emit_inbound", + frame: { + type: "sdk.response", + operation: "session.get", + data: { id: "first" }, + }, + afterMs: 1, + }, + { + type: "emit_inbound", + frame: { + type: "sdk.response", + operation: "session.get", + data: { id: "second" }, + }, + }, + ]), + ); + + const [first, second] = yield* Effect.promise(() => + Promise.all([controller.response("session.get"), controller.response("session.get")]), + ); + + assert.deepStrictEqual(first, { id: "first" }); + assert.deepStrictEqual(second, { id: "second" }); + controller.assertComplete(); + }), + ); + + it.effect("keeps a delayed SDK error claimed until its replay delay elapses", () => + Effect.gen(function* () { + const controller = new OpenCode2ReplayController( + transcript([ + { + type: "emit_inbound", + frame: { + type: "sdk.error", + operation: "session.get", + message: "delayed failure", + }, + afterMs: 20, + }, + { + type: "emit_inbound", + frame: { + type: "sdk.response", + operation: "session.get", + data: { id: "after-error" }, + }, + }, + ]), + ); + + const failure = controller.response("session.get"); + const earlyState = yield* Effect.promise(() => + Promise.race([ + failure.then( + () => "settled", + () => "settled", + ), + Promise.resolve("pending"), + ]), + ); + assert.strictEqual(earlyState, "pending"); + + const [failed, response] = yield* Effect.promise(() => + Promise.all([ + failure.then( + () => false, + () => true, + ), + controller.response("session.get"), + ]), + ); + assert.isTrue(failed); + assert.deepStrictEqual(response, { id: "after-error" }); + controller.assertComplete(); + }), + ); + + it.effect("claims distinct delayed events for concurrent subscribers", () => + Effect.gen(function* () { + const firstEvent = { + type: "session.created", + data: { id: "ses_first" }, + }; + const secondEvent = { + type: "session.created", + data: { id: "ses_second" }, + }; + const controller = new OpenCode2ReplayController( + transcript([ + { + type: "emit_inbound", + frame: { type: "sdk.event", event: firstEvent }, + afterMs: 1, + }, + { + type: "emit_inbound", + frame: { type: "sdk.event", event: secondEvent }, + }, + { type: "runtime_exit", status: "success" }, + ]), + ); + + const firstIterator = controller.events()[Symbol.asyncIterator](); + const secondIterator = controller.events()[Symbol.asyncIterator](); + const [first, second] = yield* Effect.promise(() => + Promise.all([firstIterator.next(), secondIterator.next()]), + ); + + assert.deepStrictEqual(first.value, firstEvent); + assert.deepStrictEqual(second.value, secondEvent); + controller.assertComplete(); + }), + ); + + it.effect("terminates every concurrent subscriber after a successful runtime exit", () => + Effect.gen(function* () { + const controller = new OpenCode2ReplayController( + transcript([{ type: "runtime_exit", status: "success" }]), + ); + const first = controller.events()[Symbol.asyncIterator](); + const second = controller.events()[Symbol.asyncIterator](); + + const results = yield* Effect.promise(() => Promise.all([first.next(), second.next()])); + + assert.isTrue(results[0].done === true); + assert.isTrue(results[1].done === true); + controller.assertComplete(); + }), + ); + + it.effect("rejects outbound frames after another replay consumer poisons the controller", () => + Effect.gen(function* () { + const outbound = { type: "session.get", input: { sessionID: "ses_after_failure" } }; + const controller = new OpenCode2ReplayController( + transcript([ + { type: "runtime_exit", status: "failure" }, + { type: "expect_outbound", frame: outbound }, + ]), + ); + const iterator = controller.events()[Symbol.asyncIterator](); + const failure = yield* Effect.promise(() => iterator.next().catch((cause) => cause)); + const outboundFailure = yield* Effect.promise(() => + controller.expectOutbound(outbound).catch((cause) => cause), + ); + + assert.strictEqual(outboundFailure, failure); + }), + ); + + it.effect("delivers a delayed response before the following event", () => + Effect.gen(function* () { + const event = { + type: "session.created", + data: { id: "ses_after_response" }, + }; + const controller = new OpenCode2ReplayController( + transcript([ + { + type: "emit_inbound", + frame: { + type: "sdk.response", + operation: "session.get", + data: { id: "response" }, + }, + afterMs: 1, + }, + { type: "emit_inbound", frame: { type: "sdk.event", event } }, + { type: "runtime_exit", status: "success" }, + ]), + ); + const deliveryOrder: Array = []; + const iterator = controller.events()[Symbol.asyncIterator](); + const response = controller.response("session.get").then((value) => { + deliveryOrder.push("response"); + return value; + }); + const inbound = iterator.next().then((value) => { + deliveryOrder.push("event"); + return value; + }); + + const [responseValue, eventValue] = yield* Effect.promise(() => + Promise.all([response, inbound]), + ); + + assert.deepStrictEqual(responseValue, { id: "response" }); + assert.deepStrictEqual(eventValue.value, event); + assert.deepStrictEqual(deliveryOrder, ["response", "event"]); + controller.assertComplete(); + }), + ); + + it.effect("delivers a delayed event before the following outbound request", () => + Effect.gen(function* () { + const event = { + type: "session.created", + data: { id: "ses_before_request" }, + }; + const outbound = { type: "session.get", input: { sessionID: "ses_before_request" } }; + const controller = new OpenCode2ReplayController( + transcript([ + { + type: "emit_inbound", + frame: { type: "sdk.event", event }, + afterMs: 1, + }, + { type: "expect_outbound", frame: outbound }, + { type: "runtime_exit", status: "success" }, + ]), + ); + const iterator = controller.events()[Symbol.asyncIterator](); + const inbound = iterator.next(); + const request = controller.expectOutbound(outbound); + + const [eventValue] = yield* Effect.promise(() => Promise.all([inbound, request])); + + assert.deepStrictEqual(eventValue.value, event); + controller.assertComplete(); + }), + ); + + it.effect("does not consume a delayed event after subscriber abort", () => + Effect.gen(function* () { + const event = { + type: "session.created", + data: { id: "ses_replayed" }, + }; + const controller = new OpenCode2ReplayController( + transcript([ + { + type: "emit_inbound", + frame: { type: "sdk.event", event }, + afterMs: 1, + }, + { type: "runtime_exit", status: "success" }, + ]), + ); + const abortController = new AbortController(); + const abortedIterator = controller.events(abortController.signal)[Symbol.asyncIterator](); + const abortedNext = abortedIterator.next(); + const activeIterator = controller.events()[Symbol.asyncIterator](); + const replayedNext = activeIterator.next(); + + abortController.abort(); + const [aborted, replayed] = yield* Effect.promise(() => + Promise.all([abortedNext, replayedNext]), + ); + assert.isTrue(aborted.done === true); + assert.isFalse(replayed.done === true); + assert.deepStrictEqual(replayed.value, event); + controller.assertComplete(); + }), + ); + + it.effect("routes every adapter replay client operation through the transcript", () => + Effect.gen(function* () { + const agentInput = { location: { directory: "/workspace" } }; + const mcpInput = { location: { directory: "/workspace" } }; + const instructionsInput = { + sessionID: "ses_1", + key: "t3-instructions", + value: { prompt: "Use the T3 tools." }, + }; + const controller = new OpenCode2ReplayController( + transcript([ + { type: "expect_outbound", frame: { type: "agent.list", input: agentInput } }, + { + type: "emit_inbound", + frame: { type: "sdk.response", operation: "agent.list", data: [] }, + }, + { type: "expect_outbound", frame: { type: "mcp.list", input: mcpInput } }, + { + type: "emit_inbound", + frame: { type: "sdk.response", operation: "mcp.list", data: [] }, + }, + { + type: "expect_outbound", + frame: { type: "session.instructions.entry.put", input: instructionsInput }, + }, + { + type: "emit_inbound", + frame: { + type: "sdk.response", + operation: "session.instructions.entry.put", + data: {}, + }, + }, + ]), + ); + const client = makeReplayClient(controller); + + yield* Effect.promise(async () => { + await client.v2.agent.list(agentInput); + await client.v2.mcp.list(mcpInput); + await client.v2.session.instructions.entry.put(instructionsInput); + }); + controller.assertComplete(); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts new file mode 100644 index 00000000000..921bdd744dc --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -0,0 +1,523 @@ +import type { OpencodeClient, V2Event } from "@opencode-ai/sdk-next/v2"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + ProviderInstanceId, + ProviderReplayEntry, + type ProviderReplayTranscript, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as P from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +import { ServerConfig } from "../../config.ts"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../../provider/Layers/ProviderEventLoggers.ts"; +import { + OpenCode2Runtime, + OpenCode2RuntimeError, + type OpenCode2RuntimeOperation, +} from "../../provider/opencode2Runtime.ts"; +import { layer as idAllocatorLayer } from "../IdAllocator.ts"; +import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts"; +import { makeDriverLayer as makeProviderAdapterRegistryDriverLayer } from "../ProviderAdapterRegistry.ts"; +import { + makeReplayServerConfig, + type OrchestratorV2ProviderReplayHarness, +} from "../testkit/ProviderReplayHarness.ts"; +import { + OPENCODE2_DRIVER_KIND, + OPENCODE2_PROVIDER, + OPENCODE2_SDK_PROTOCOL, + OpenCode2AdapterV2Driver, +} from "./OpenCode2AdapterV2.ts"; + +export const OPENCODE2_SDK_REPLAY_PROTOCOL = OPENCODE2_SDK_PROTOCOL; +export const OPENCODE2_REPLAY_INSTANCE_ID = ProviderInstanceId.make("opencode2"); + +const OpenCode2SdkReplayTranscript = Schema.Struct({ + provider: Schema.Literal(OPENCODE2_PROVIDER), + protocol: Schema.Literal(OPENCODE2_SDK_REPLAY_PROTOCOL), + version: Schema.String, + scenario: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + entries: Schema.Array(ProviderReplayEntry), +}); +export type OpenCode2SdkReplayTranscript = typeof OpenCode2SdkReplayTranscript.Type; +const decodeOpenCode2SdkReplayTranscript = Schema.decodeUnknownEffect(OpenCode2SdkReplayTranscript); + +export class OpenCode2ReplayTranscriptDecodeError extends Schema.TaggedErrorClass()( + "OpenCode2ReplayTranscriptDecodeError", + { + driver: Schema.optional(Schema.String), + protocol: Schema.optional(Schema.String), + scenario: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode OpenCode 2 replay transcript for scenario ${this.scenario ?? ""}.`; + } +} + +export class OpenCode2ReplayMismatchError extends Schema.TaggedErrorClass()( + "OpenCode2ReplayMismatchError", + { + scenario: Schema.String, + cursor: Schema.Number, + expected: Schema.Unknown, + actual: Schema.Unknown, + }, +) { + override get message(): string { + return `OpenCode 2 replay frame mismatch at cursor ${this.cursor} in scenario ${this.scenario}.`; + } +} + +export class OpenCode2ReplayIncompleteError extends Schema.TaggedErrorClass()( + "OpenCode2ReplayIncompleteError", + { + scenario: Schema.String, + cursor: Schema.Number, + remaining: Schema.Number, + }, +) { + override get message(): string { + return `OpenCode 2 replay ended with ${this.remaining} unconsumed entries in scenario ${this.scenario}.`; + } +} + +export const OpenCode2ReplayError = Schema.Union([ + OpenCode2ReplayTranscriptDecodeError, + OpenCode2ReplayMismatchError, + OpenCode2ReplayIncompleteError, +]); +export type OpenCode2ReplayError = typeof OpenCode2ReplayError.Type; +export const OpenCode2OrchestratorReplayHarnessError = Schema.Union([ + OpenCode2ReplayError, + ProviderAdapterDriverCreateError, +]); +export type OpenCode2OrchestratorReplayHarnessError = + typeof OpenCode2OrchestratorReplayHarnessError.Type; + +function replayValueMatches(expected: unknown, actual: unknown): boolean { + if (expected === "" || expected === "") return true; + if (Array.isArray(expected)) { + return ( + Array.isArray(actual) && + expected.length === actual.length && + expected.every((entry, index) => replayValueMatches(entry, actual[index])) + ); + } + if (P.isObject(expected)) { + if (!P.isObject(actual)) return false; + return Object.entries(expected).every(([key, value]) => replayValueMatches(value, actual[key])); + } + return Object.is(expected, actual); +} + +function frameRecord(frame: unknown): Record | null { + return P.isObject(frame) ? frame : null; +} + +function isSignalAborted(signal?: AbortSignal): boolean { + return signal?.aborted === true; +} + +async function waitForReplayDelay(afterMs: number, signal?: AbortSignal): Promise { + if (isSignalAborted(signal)) return false; + if (signal === undefined) { + await Effect.runPromise(Effect.sleep(Duration.millis(afterMs))); + return true; + } + return new Promise((resolve) => { + let settled = false; + const done = (completed: boolean) => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", abort); + resolve(completed); + }; + const abort = () => done(false); + signal.addEventListener("abort", abort, { once: true }); + void Effect.runPromise(Effect.sleep(Duration.millis(afterMs))).then(() => done(true)); + if (signal.aborted) abort(); + }); +} + +export class OpenCode2ReplayController { + private cursor = 0; + private claimedEventCursor: number | null = null; + private claimedResponseCursor: number | null = null; + private successfulRuntimeExit = false; + private readonly waiters = new Set<() => void>(); + private failure: unknown = null; + private readonly transcript: OpenCode2SdkReplayTranscript; + + constructor(transcript: OpenCode2SdkReplayTranscript) { + this.transcript = transcript; + } + + async expectOutbound(actual: unknown): Promise { + try { + this.throwFailure(); + while ( + this.claimedEventCursor === this.cursor || + this.claimedResponseCursor === this.cursor + ) { + await this.changed(); + this.throwFailure(); + } + const entry = this.transcript.entries[this.cursor]; + if (entry?.type !== "expect_outbound" || !replayValueMatches(entry.frame, actual)) { + throw new OpenCode2ReplayMismatchError({ + scenario: this.transcript.scenario, + cursor: this.cursor, + expected: entry?.type === "expect_outbound" ? entry.frame : (entry ?? null), + actual, + }); + } + this.advance(); + } catch (cause) { + this.fail(cause); + throw cause; + } + } + + async response(operation: OpenCode2RuntimeOperation): Promise { + while (true) { + this.throwFailure(); + if (this.claimedResponseCursor === this.cursor) { + await this.changed(); + continue; + } + const entry = this.transcript.entries[this.cursor]; + if (entry === undefined) { + const mismatch = new OpenCode2ReplayMismatchError({ + scenario: this.transcript.scenario, + cursor: this.cursor, + expected: { type: "sdk.response", operation }, + actual: null, + }); + this.fail(mismatch); + throw mismatch; + } + if (entry?.type === "emit_inbound") { + const frame = frameRecord(entry.frame); + if (frame?.type === "sdk.response" && frame.operation === operation) { + const data = frame.data; + const claimedCursor = this.cursor; + this.claimedResponseCursor = claimedCursor; + try { + if (entry.afterMs !== undefined && entry.afterMs > 0) { + await Effect.runPromise(Effect.sleep(Duration.millis(entry.afterMs))); + } + this.throwFailure(); + this.advance(); + return data; + } finally { + this.releaseResponseClaim(claimedCursor); + } + } + if (frame?.type === "sdk.error" && frame.operation === operation) { + const claimedCursor = this.cursor; + this.claimedResponseCursor = claimedCursor; + try { + if (entry.afterMs !== undefined && entry.afterMs > 0) { + await Effect.runPromise(Effect.sleep(Duration.millis(entry.afterMs))); + } + this.throwFailure(); + this.advance(); + throw new OpenCode2RuntimeError({ + operation, + category: "sdk-request-failed", + cause: frame.error ?? frame.message, + }); + } finally { + this.releaseResponseClaim(claimedCursor); + } + } + } + if (entry?.type === "runtime_exit") { + const mismatch = new OpenCode2ReplayMismatchError({ + scenario: this.transcript.scenario, + cursor: this.cursor, + expected: { type: "sdk.response", operation }, + actual: entry, + }); + this.fail(mismatch); + throw mismatch; + } + await this.changed(); + } + } + + async *events(signal?: AbortSignal): AsyncIterable { + while (true) { + if (isSignalAborted(signal)) return; + this.throwFailure(); + if (this.successfulRuntimeExit) return; + if (this.claimedEventCursor === this.cursor || this.claimedResponseCursor === this.cursor) { + await this.changed(signal); + continue; + } + const entry = this.transcript.entries[this.cursor]; + if (entry?.type === "emit_inbound") { + const frame = frameRecord(entry.frame); + if (frame?.type === "sdk.event") { + const claimedCursor = this.cursor; + this.claimedEventCursor = claimedCursor; + try { + if (entry.afterMs !== undefined && entry.afterMs > 0) { + const delayCompleted = await waitForReplayDelay(entry.afterMs, signal); + if (!delayCompleted || isSignalAborted(signal)) return; + } + this.throwFailure(); + const event = frame.event as V2Event; + this.advance(); + this.releaseEventClaim(claimedCursor); + yield event; + continue; + } finally { + this.releaseEventClaim(claimedCursor); + } + } + } + if (entry?.type === "runtime_exit") { + if (entry.status === "success") { + this.successfulRuntimeExit = true; + this.advance(); + return; + } + this.advance(); + const mismatch = new OpenCode2ReplayMismatchError({ + scenario: this.transcript.scenario, + cursor: this.cursor - 1, + expected: { status: "success" }, + actual: entry, + }); + this.fail(mismatch); + throw mismatch; + } + await this.changed(signal); + } + } + + assertComplete(): void { + while (this.transcript.entries[this.cursor]?.type === "runtime_exit") { + const exit = this.transcript.entries[this.cursor]; + if (exit?.type !== "runtime_exit" || exit.status !== "success") break; + this.successfulRuntimeExit = true; + this.advance(); + } + this.throwFailure(); + if (this.cursor !== this.transcript.entries.length) { + throw new OpenCode2ReplayIncompleteError({ + scenario: this.transcript.scenario, + cursor: this.cursor, + remaining: this.transcript.entries.length - this.cursor, + }); + } + } + + private advance(): void { + this.cursor += 1; + this.notifyWaiters(); + } + + private releaseEventClaim(cursor: number): void { + if (this.claimedEventCursor !== cursor) return; + this.claimedEventCursor = null; + this.notifyWaiters(); + } + + private releaseResponseClaim(cursor: number): void { + if (this.claimedResponseCursor !== cursor) return; + this.claimedResponseCursor = null; + this.notifyWaiters(); + } + + private notifyWaiters(): void { + for (const waiter of this.waiters) waiter(); + this.waiters.clear(); + } + + private fail(cause: unknown): void { + this.failure = cause; + this.notifyWaiters(); + } + + private throwFailure(): void { + if (this.failure !== null) throw this.failure; + } + + private changed(signal?: AbortSignal): Promise { + if (signal?.aborted === true) return Promise.resolve(); + return new Promise((resolve) => { + const done = () => { + signal?.removeEventListener("abort", done); + this.waiters.delete(done); + resolve(); + }; + this.waiters.add(done); + signal?.addEventListener("abort", done, { once: true }); + }); + } +} + +export function makeReplayClient(controller: OpenCode2ReplayController): OpencodeClient { + const request = async (operation: OpenCode2RuntimeOperation, input: unknown) => { + await controller.expectOutbound({ type: operation, input }); + return { data: { data: await controller.response(operation) } }; + }; + return { + v2: { + agent: { + list: (input: unknown) => request("agent.list", input), + }, + event: { + subscribe: async (options?: { readonly signal?: AbortSignal }) => { + await controller.expectOutbound({ type: "event.subscribe" }); + return { stream: controller.events(options?.signal) }; + }, + }, + message: { + list: (input: unknown) => request("message.list", input), + }, + mcp: { + list: (input: unknown) => request("mcp.list", input), + }, + model: { + list: (input: unknown) => request("model.list", input), + }, + session: { + create: (input: unknown) => request("session.create", input), + fork: (input: unknown) => request("session.fork", input), + get: (input: unknown) => request("session.get", input), + interrupt: (input: unknown) => request("session.interrupt", input), + instructions: { + entry: { + put: (input: unknown) => request("session.instructions.entry.put", input), + }, + }, + pending: { + list: (input: unknown) => request("session.pending.list", input), + }, + permission: { + reply: (input: unknown) => request("session.permission.reply", input), + }, + prompt: (input: unknown) => request("session.prompt", input), + remove: (input: unknown) => request("session.remove", input), + question: { + reply: (input: unknown) => request("session.question.reply", input), + }, + revert: { + commit: (input: unknown) => request("session.revert.commit", input), + stage: (input: unknown) => request("session.revert.stage", input), + }, + form: { + reply: (input: unknown) => request("session.form.reply", input), + }, + switchAgent: (input: unknown) => request("session.switchAgent", input), + switchModel: (input: unknown) => request("session.switchModel", input), + }, + shell: { + list: (input: unknown) => request("shell.list", input), + output: (input: unknown) => request("shell.output", input), + remove: (input: unknown) => request("shell.remove", input), + }, + }, + } as unknown as OpencodeClient; +} + +function makeOpenCode2ReplayRuntimeLayer(transcript: OpenCode2SdkReplayTranscript) { + return Layer.effect( + OpenCode2Runtime, + Effect.gen(function* () { + const controller = new OpenCode2ReplayController(transcript); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + controller.assertComplete(); + }), + ); + const client = makeReplayClient(controller); + return OpenCode2Runtime.of({ + startOpenCode2ServerProcess: () => + Effect.fail( + new OpenCode2RuntimeError({ + operation: "startOpenCode2ServerProcess", + category: "replay-boundary", + }), + ), + connectToOpenCode2Server: () => + Effect.succeed({ + url: "replay://opencode2", + password: "replay-password", + exitCode: null, + external: true, + }), + createOpenCode2SdkClient: () => client, + } satisfies OpenCode2Runtime["Service"]); + }), + ); +} + +export function makeOpenCode2ProviderAdapterRegistryReplayLayer( + transcript: OpenCode2SdkReplayTranscript, +) { + const serverConfigLayer = Layer.effect( + ServerConfig, + makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie), + ).pipe(Layer.provide(NodeServices.layer)); + return makeProviderAdapterRegistryDriverLayer({ + drivers: [OpenCode2AdapterV2Driver], + configMap: { + [OPENCODE2_REPLAY_INSTANCE_ID]: { + driver: OPENCODE2_DRIVER_KIND, + config: { + serverUrl: "replay://opencode2", + serverPassword: "replay-password", + }, + }, + }, + }).pipe( + Layer.provide( + Layer.mergeAll( + makeOpenCode2ReplayRuntimeLayer(transcript), + serverConfigLayer, + NodeServices.layer, + idAllocatorLayer, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ), + ), + ); +} + +function transcriptMetadata(transcript: ProviderReplayTranscript) { + return { + driver: transcript.provider, + protocol: transcript.protocol, + scenario: transcript.scenario, + }; +} + +export const OpenCode2OrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< + OpenCode2SdkReplayTranscript, + OpenCode2OrchestratorReplayHarnessError +> = { + driver: OPENCODE2_PROVIDER, + decodeTranscript: (transcript) => + decodeOpenCode2SdkReplayTranscript(transcript).pipe( + Effect.mapError( + (cause) => + new OpenCode2ReplayTranscriptDecodeError({ + ...transcriptMetadata(transcript), + cause, + }), + ), + ), + makeProviderAdapterRegistryLayer: makeOpenCode2ProviderAdapterRegistryReplayLayer, +}; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts new file mode 100644 index 00000000000..f53814e54a4 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -0,0 +1,5080 @@ +/** + * OpenCode 2.x ("OpenCode 2") orchestration adapter. + * + * A separate adapter rather than a mode of `OpenCodeAdapterV2`: 2.x shares the + * vendor name and the tool vocabulary with 1.x and nothing else. Concretely, + * + * - the wire surface is `/api/*` only, reached through `client.v2.*`; + * - every response is double-wrapped, `{ data: { data: … } }`, because the + * SDK's own `.data` is the parsed body and the body carries its own + * envelope; + * - the event vocabulary is a flat stream of typed lifecycle events + * (`session.text.started` / `.delta` / `.ended`, `session.tool.*`, + * `session.execution.*`) rather than 1.x's `message.part.updated` carrying + * a whole part object; + * - the model binds at session create via `ModelRef`, not per prompt; + * - permission asks can still arrive under the legacy `permission.asked` + * name, but replies always use the `/api` session-scoped + * `client.v2.session.*` routes. Self-spawned full-access servers also get + * `permission: "allow"` injected into `OPENCODE_CONFIG_CONTENT` at spawn. + * + * `live-scenarios/tests/opencode2-drive-probe.mjs` in the parent workspace is + * the executable statement of this contract against a real binary. + * + * @module orchestration-v2/Adapters/OpenCode2AdapterV2 + */ +import type { + AgentInfoV2, + FormInfo, + McpServer, + ModelInfo, + PromptInputFileAttachment, + QuestionV2Info, + SessionInfoV2, + SessionMessageInfo, + SessionPendingInfo, + ShellInfoV2, + V2Event, +} from "@opencode-ai/sdk-next/v2"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import { + type ChatAttachment, + type ModelSelection, + type OpenCode2Settings, + OpenCode2Settings as OpenCode2SettingsSchema, + type OrchestrationV2AppThread, + type OrchestrationV2ConversationMessage, + type OrchestrationV2ExecutionNode, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderFailure, + type OrchestrationV2ProviderRef, + type OrchestrationV2ProviderRetry, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2RuntimeRequest, + type OrchestrationV2Subagent, + type OrchestrationV2TurnItem, + ProviderDriverKind, + type ProviderInstanceId, + type ProviderInteractionMode, + type ProviderRequestKind, + type ProviderSessionId, + type RuntimeRequestId, + type ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +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 Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as NodeURL from "node:url"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; +import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; +import { + structuralProtocolMethod, + summarizeNativeProtocolPayload, +} from "../../provider/NativeProtocolLogging.ts"; +import { + normalizeOpenCode2Variant, + OPENCODE2_AUTO_AGENT, + OpenCode2Runtime, + OpenCode2RuntimeError, + runOpenCode2Sdk, + type OpenCode2RuntimeOperation, +} from "../../provider/opencode2Runtime.ts"; +import { + openCodeRuntimeErrorDetail, + parseOpenCodeModelSlug, +} from "../../provider/opencodeRuntime.ts"; +import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; +import { applyOpenCode2ProviderEnvironment } from "../../provider/OpenCode2ProviderEnvironment.ts"; +import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts"; +import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts"; +import { makeProviderFailure, makeProviderFailureTurnItem } from "../ProviderFailure.ts"; +import { + type ProviderContinuationRequest, + ProviderContinuationRequests, +} from "../ProviderContinuationRequests.ts"; +import { + type ProviderInteractionModeReflection, + ProviderInteractionModeReflections, +} from "../ProviderInteractionModeReflections.ts"; +import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; +import { + makeSubagentChildThread, + makeSubagentConversationArtifacts, + subagentThreadTitle, +} from "../SubagentProjection.ts"; +import { + ProviderAdapterEnsureThreadError, + ProviderAdapterForkThreadError, + ProviderAdapterInterruptError, + ProviderAdapterOpenSessionError, + ProviderAdapterProtocolError, + ProviderAdapterReadThreadSnapshotError, + ProviderAdapterResumeThreadError, + ProviderAdapterRollbackThreadError, + ProviderAdapterRuntimeRequestResponseError, + ProviderAdapterSteerRunError, + ProviderAdapterTurnStartError, + ProviderAdapterV2, + type ProviderAdapterV2Event, + type ProviderAdapterV2OpenSessionInput, + type ProviderAdapterV2SessionRuntime, + type ProviderAdapterV2Shape, + type ProviderAdapterV2TurnInput, +} from "../ProviderAdapter.ts"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "../ProviderAdapterDriver.ts"; +// Tool names, permission actions, and the terminal-status mapping are the one +// thing 1.x and 2.x genuinely share, so these classifiers stay in one place +// rather than drifting between two copies. +import { + openCodeBoundaryAfterProviderTurn, + openCodePermissionRequestKind, + openCodePermissionRules, + openCodeToolProjectionKind, + terminalToolStatus, +} from "./OpenCodeAdapterV2.ts"; + +export const OPENCODE2_PROVIDER = ProviderDriverKind.make("opencode2"); +export const OPENCODE2_DRIVER_KIND = OPENCODE2_PROVIDER; +export const OPENCODE2_SDK_PROTOCOL = "opencode2-sdk.sse" as const; +export const OPENCODE2_RETIRED_SUPPRESS_WAKE_LIMIT = 16; +export const OPENCODE2_PROMOTED_INPUT_ID_LIMIT = 64; +const DEFAULT_OPENCODE2_SETTINGS = Schema.decodeSync(OpenCode2SettingsSchema)({}); +const OPENCODE2_T3_MCP_NAME = "t3-code"; +const OPENCODE2_T3_INSTRUCTION_KEY = "t3-code.orchestration"; +const OpenCode2InlineConfig = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); +const OpenCode2McpConfig = Schema.Record(Schema.String, Schema.Unknown); +const decodeOpenCode2InlineConfig = Schema.decodeUnknownEffect(OpenCode2InlineConfig); +const decodeOpenCode2McpConfig = Schema.decodeUnknownEffect(OpenCode2McpConfig); +const encodeOpenCode2InlineConfig = Schema.encodeEffect(OpenCode2InlineConfig); + +/** + * 2.x keeps 1.x's durable session/message identifiers and adds a durable + * execution, but it still exposes no first-class turn object: the admitted + * session input is the closest native correlation point, and the + * `session.execution.*` terminal events are the authoritative settle signal. + * + * Native subagent sessions expose their parent session through + * `session.created.info.parentID`. The parent subagent tool also reports the + * child session id in its progress or terminal metadata, so the adapter can + * project durable child lineage and route child requests through the root + * turn's runtime policy. + */ +export const OpenCode2ProviderCapabilitiesV2 = { + sessions: { + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: true, + supportsProviderSwitchingViaHandoff: true, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: true, + canRollbackThread: true, + canForkThread: true, + canForkFromTurn: true, + canForkFromSubagentThread: true, + exposesNativeThreadId: true, + }, + turns: { + exposesNativeTurnId: false, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: true, + supportsSteeringByInterruptRestart: true, + supportsQueuedMessages: true, + terminalStatusQuality: "strong", + }, + streaming: { + streamsAssistantText: true, + streamsReasoning: true, + streamsToolOutput: true, + streamsPlanText: false, + emitsMessageCompleted: true, + }, + tools: { + exposesToolItemIds: true, + emitsToolStarted: true, + emitsToolCompleted: true, + emitsToolOutput: true, + supportsMcpTools: true, + supportsDynamicToolCallbacks: false, + }, + approvals: { + supportsCommandApproval: true, + supportsFileReadApproval: true, + supportsFileChangeApproval: true, + supportsApplyPatchApproval: true, + approvalsHaveNativeRequestIds: true, + approvalCallbacksAreLiveOnly: true, + approvalsCanOriginateFromSubagents: true, + }, + planning: { + emitsPlanUpdated: false, + emitsTodoList: false, + emitsProposedPlan: false, + supportsStructuredQuestions: true, + planDeltasHaveItemIds: false, + }, + subagents: { + supportsSubagents: true, + exposesSubagentThreadIds: true, + emitsSubagentLifecycle: true, + canWaitForSubagents: false, + canCloseSubagents: false, + canForkSubagentThread: true, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: true, + canGenerateSummaries: true, + canConsumeHandoffSummaries: true, + supportsDeltaHandoff: true, + supportsFullThreadHandoff: true, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: true, + supportsNestedCheckpointScopes: true, + providerCanRollbackConversation: true, + providerRollbackReturnsSnapshot: true, + providerCanReadConversationSnapshot: true, + }, + identity: { + nativeThreadIds: "strong", + nativeTurnIds: "weak", + nativeItemIds: "strong", + nativeRequestIds: "strong", + }, +} satisfies OrchestrationV2ProviderCapabilities; + +type TerminalTurnStatus = Extract< + OrchestrationV2ProviderTurn["status"], + "completed" | "interrupted" | "failed" | "cancelled" +>; + +type OpenCode2ToolStatus = "pending" | "running" | "completed" | "error"; + +interface OpenCode2TextPart { + readonly kind: "text" | "reasoning"; + readonly id: string; + readonly startedAt: DateTime.Utc; + text: string; + completed: boolean; +} + +interface OpenCode2ToolPart { + readonly kind: "tool"; + readonly id: string; + readonly callId: string; + readonly startedAt: DateTime.Utc; + name: string; + input: Record; + inputText: string; + output: string | undefined; + structured: Record | undefined; + status: OpenCode2ToolStatus; + errorMessage: string | undefined; + completedAt: DateTime.Utc | null; +} + +interface OpenCode2ShellProjection { + readonly shellId: string; + readonly state: OpenCode2ThreadState; + readonly turn: ActiveOpenCode2Turn; + readonly part: OpenCode2ToolPart; + readonly location: SessionInfoV2["location"]; + status: ShellInfoV2["status"]; +} + +interface OpenCode2Compaction { + readonly id: string; + readonly startedAt: DateTime.Utc; + summary: string; + status: "running" | "completed" | "failed" | "cancelled"; + completedAt: DateTime.Utc | null; +} + +type OpenCode2Part = OpenCode2TextPart | OpenCode2ToolPart; + +interface ActiveOpenCode2Turn { + readonly isRoot: boolean; + readonly providerBufferedContinuation: boolean; + readonly threadId: ThreadId; + readonly runId: OrchestrationV2ExecutionNode["runId"]; + readonly rootNodeId: OrchestrationV2ExecutionNode["rootNodeId"]; + readonly appThread: OrchestrationV2AppThread; + readonly modelSelection: ModelSelection; + readonly runtimePolicy: ProviderAdapterV2TurnInput["runtimePolicy"]; + readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; + readonly runOrdinal: number; + readonly startedAt: DateTime.Utc; + readonly itemOrdinals: Map; + readonly parts: Map; + readonly toolIdsByCallId: Map; + readonly providerTurn: OrchestrationV2ProviderTurn; + nextItemOrdinal: number; + nativeInputId: string | null; + activeCompaction: OpenCode2Compaction | null; + executionStarted: boolean; + interrupted: boolean; + finalized: boolean; + providerRetry: OpenCode2ProviderRetry | null; +} + +interface OpenCode2SubagentContext { + readonly nativeItemId: string; + readonly nodeId: OrchestrationV2Subagent["id"]; + readonly turnItemId: OrchestrationV2TurnItem["id"]; + readonly parentState: OpenCode2ThreadState; + readonly parentTurn: ActiveOpenCode2Turn; + readonly startedAt: DateTime.Utc; + completedAt: DateTime.Utc | null; + prompt: string; + title: string | null; + model: string | null; + childSessionId: string | null; + childThreadId: ThreadId | null; + childProviderThreadId: OrchestrationV2ProviderThread["id"] | null; + status: OrchestrationV2Subagent["status"]; + progress: string | undefined; + result: string | null; +} + +interface OpenCode2ProviderRetry { + readonly retry: OrchestrationV2ProviderRetry; + readonly failure: OrchestrationV2ProviderFailure; + readonly startedAt: DateTime.Utc; +} + +type OpenCode2PostSettleWakeDisposition = "replay" | "suppress"; +type OpenCode2PostSettleWakePhase = "pending" | "executing" | "ready"; + +interface OpenCode2ExecutionOwnership { + readonly inputIds: Set; + claimedByPromotion: boolean; +} + +interface OpenCode2EventHandlingContext { + readonly replayWakeInputId?: string; +} + +interface OpenCode2PostSettleWake { + readonly inputId: string; + readonly events: Array; + readonly disposition: OpenCode2PostSettleWakeDisposition; + promotedAfterExecutionStarted: boolean; + phase: OpenCode2PostSettleWakePhase; +} + +/** Keep the newest retired suppression evidence in insertion order. */ +export function pruneOpenCode2RetiredSuppressWakes(wakes: Map): void { + while (wakes.size > OPENCODE2_RETIRED_SUPPRESS_WAKE_LIMIT) { + const oldestInputId = wakes.keys().next().value; + if (oldestInputId === undefined) return; + wakes.delete(oldestInputId); + } +} + +/** Keep recent promotion evidence for late admissions without unbounded state. */ +export function pruneOpenCode2PromotedInputIds(inputIds: Set): void { + while (inputIds.size > OPENCODE2_PROMOTED_INPUT_ID_LIMIT) { + const oldestInputId = inputIds.values().next().value; + if (oldestInputId === undefined) return; + inputIds.delete(oldestInputId); + } +} + +interface OpenCode2ThreadState { + readonly nativeSessionId: string; + location: SessionInfoV2["location"]; + providerThread: OrchestrationV2ProviderThread; + appThread: OrchestrationV2AppThread | null; + activeTurn: ActiveOpenCode2Turn | null; + boundModel: string | null; + boundVariant: string | null; + boundAgent: string | null; + lastAgentSelectedEventId: string | null; + readonly providerTurns: Map; + readonly messages: Map; + readonly runtimeRequests: Map; + readonly postSettleWakes: Array; + readonly retiredSuppressWakes: Map; + /** + * Unclaimed promotion evidence. Ownership removes an id; unmatched ids + * remain so a genuinely later admission can still claim it, with a bounded + * insertion-order window preventing stale ids from growing state forever. + */ + readonly promotedInputIds: Set; + sawInputPromotion: boolean; + activeExecution: OpenCode2ExecutionOwnership | null; + parentSubagent: OpenCode2SubagentContext | null; + nextChildTurnOrdinal: number; +} + +interface PendingOpenCode2Request { + readonly requestId: RuntimeRequestId; + readonly nativeRequestId: string; + readonly nativeSessionId: string; + readonly turn: ActiveOpenCode2Turn; + readonly state: OpenCode2ThreadState; + readonly nodeId: OrchestrationV2ExecutionNode["id"]; + readonly turnItemId: OrchestrationV2TurnItem["id"]; + readonly requestKind: OrchestrationV2RuntimeRequest["kind"]; + readonly createdAt: DateTime.Utc; + readonly permission?: { + readonly action: string; + readonly resources: ReadonlyArray; + readonly save: ReadonlyArray; + }; + readonly questions?: ReadonlyArray; + /** + * Present when the questions came from a form (`form.created`); index-aligned + * with `questions`, and the reply must go through `session.form.reply`. + */ + readonly formFieldKeys?: ReadonlyArray; + /** Index-aligned label-to-value maps for translating UI answers. */ + readonly formOptionValues?: ReadonlyArray>>; +} + +export interface OpenCode2SessionPermission { + readonly action: string; + readonly resources: ReadonlyArray; +} + +export type OpenCode2SessionPermissionStore = Map>; + +export interface OpenCode2AdapterV2Options { + readonly instanceId: ProviderInstanceId; + readonly settings: OpenCode2Settings; + readonly environment: NodeJS.ProcessEnv; + readonly runtime: OpenCode2Runtime["Service"]; + readonly idAllocator: IdAllocatorV2Shape; + readonly serverConfig: ServerConfig["Service"]; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly continuationRequests?: { + readonly offer: (request: ProviderContinuationRequest) => Effect.Effect; + }; + readonly interactionModeReflections?: { + readonly offer: (request: ProviderInteractionModeReflection) => Effect.Effect; + }; +} + +/** + * OpenCode admits a background-child result as a synthetic root input. The + * admission itself does not identify its execution boundary: an ordinary + * input and multiple synthetic inputs may be admitted together, and a later + * promotion event assigns one or more of them to an execution. + */ +export function openCode2IsPostSettleWakeAdmission( + event: V2Event, + state: { readonly isChildSession: boolean }, +): event is Extract { + if ( + event.type !== "session.input.admitted" || + event.data.input.type !== "synthetic" || + state.isChildSession + ) { + return false; + } + const data = event.data.input.data; + const source = recordString(recordValue(data, "metadata"), "source"); + const text = recordString(data, "text"); + return source !== undefined || /^\s*<(?:subagent|shell)\b/i.test(text ?? ""); +} + +/** + * An interrupted provider-native child is terminal work, not a successful + * background result for the parent's next turn. OpenCode reports that result + * as a synthetic root input, so keep the cancellation boundary here rather + * than teaching the generic continuation machinery about provider wire data. + */ +export function openCode2IsCancelledPostSettleWake(event: V2Event): boolean { + if (event.type !== "session.input.admitted" || event.data.input.type !== "synthetic") { + return false; + } + const text = recordString(event.data.input.data, "text"); + // The completed wrapper shape comes from captured pre-existing OpenCode 2 + // replay data. The cancelled and interrupted values are inferred from + // OpenCode behavior, not adapter behavior. An exact raw-payload capture + // remains an explicit in-vivo gate before treating this as a contract. + const value = text ?? ""; + return ( + /^\s*]*\sstate\s*=\s*["'](?:cancelled|interrupted)["'])[^>]*>/i.test(value) || + /^\s*]*\sstate\s*=\s*["']error["'])[^>]*>\s*<\/shell>\s*$/i.test(value) + ); +} + +export function openCode2EventEndsExecution(event: V2Event): boolean { + return ( + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" || + event.type === "session.idle" + ); +} + +export const openCode2PendingWorkForSession = Effect.fnUntraced(function* (input: { + readonly sessionID: string; + readonly pending: Effect.Effect, OpenCode2RuntimeError>; + readonly shells: Effect.Effect, OpenCode2RuntimeError>; +}) { + const pending = yield* input.pending; + if (pending.some((item) => item.sessionID === input.sessionID)) { + return true; + } + const shells = yield* input.shells; + return shells.some( + (shell) => shell.status === "running" && shell.metadata.sessionID === input.sessionID, + ); +}); + +export function openCode2ToolNeedsTerminalOverride( + part: Pick, + terminal: TerminalTurnStatus, +): boolean { + if (part.status === "pending" || part.status === "running") return true; + return ( + terminal === "interrupted" && + part.status === "error" && + part.errorMessage === "Tool execution interrupted" + ); +} + +type OpenCode2SessionErrorData = Extract["data"]; + +export function openCode2SessionErrorMessage(data: OpenCode2SessionErrorData): string { + const error = data.error; + if (error === undefined) return "OpenCode 2 reported a session error."; + return recordString(error.data, "message") ?? error.name; +} + +export function openCode2SessionErrorStatus( + data: OpenCode2SessionErrorData, + interrupted: boolean, +): TerminalTurnStatus { + return interrupted || data.error?.name === "MessageAbortedError" ? "interrupted" : "failed"; +} + +export function openCode2SessionErrorTargetSessionIds( + sessionID: string | undefined, + activeSessionIDs: ReadonlyArray, +): ReadonlyArray { + if (sessionID === undefined) return activeSessionIDs; + return activeSessionIDs.includes(sessionID) ? [sessionID] : []; +} + +export function openCode2InterruptedThreadDisposition( + reason: Extract["data"]["reason"], +): "reusable" | "broken" { + return reason === "shutdown" ? "broken" : "reusable"; +} + +export function openCode2ShouldSettleTurn( + source: "execution-terminal" | "execution-interrupted" | "idle", + executionStarted: boolean, + interruptRequested = false, +): boolean { + if (source === "idle") return !executionStarted; + if (source === "execution-interrupted") return executionStarted || interruptRequested; + return executionStarted; +} + +export interface OpenCode2ProtocolLogEvent { + readonly direction: "incoming" | "outgoing"; + readonly messageKind: "request" | "response" | "notification" | "error"; + readonly method: string; + readonly payload: unknown; +} + +export function makeOpenCode2ProtocolLogger(input: { + readonly nativeEventLogger: EventNdjsonLogger | undefined; + readonly idAllocator: IdAllocatorV2Shape; + readonly providerInstanceId: ProviderInstanceId; + readonly providerSessionId: ProviderSessionId; + readonly threadId: ThreadId; +}): (event: OpenCode2ProtocolLogEvent) => Effect.Effect { + return (event) => + Effect.gen(function* () { + if (!input.nativeEventLogger) return; + const observedAt = DateTime.formatIso(yield* DateTime.now); + const method = structuralProtocolMethod(event.method); + yield* input.nativeEventLogger.write( + { + observedAt, + event: { + id: yield* input.idAllocator.allocate.rawEvent({ + providerSessionId: input.providerSessionId, + method, + }), + kind: "protocol", + protocol: OPENCODE2_SDK_PROTOCOL, + provider: OPENCODE2_PROVIDER, + providerInstanceId: input.providerInstanceId, + providerSessionId: input.providerSessionId, + createdAt: observedAt, + threadId: input.threadId, + payload: { + direction: event.direction, + messageKind: event.messageKind, + method, + payload: summarizeNativeProtocolPayload(event.payload), + }, + }, + }, + input.threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("Failed to write native OpenCode 2 event log.", { + errorTag: causeErrorTag(cause), + reasonCount: cause.reasons.length, + provider: OPENCODE2_PROVIDER, + threadId: input.threadId, + }), + ), + ); +} + +function protocolError(detail: string, payload?: unknown): ProviderAdapterProtocolError { + return new ProviderAdapterProtocolError({ + driver: OPENCODE2_PROVIDER, + detail, + ...(payload === undefined ? {} : { payload }), + }); +} + +/** + * Builds the native selection fragment shared by session creation and + * subsequent model or agent switches. + * + * @internal exported for tests + */ +export function openCode2SessionSelectionParameters( + modelSelection: ModelSelection, + interactionMode?: ProviderInteractionMode, + knownAgentIDs?: ReadonlySet | null, +) { + const parsed = parseOpenCodeModelSlug(modelSelection.model); + if (parsed === null) { + throw protocolError( + `OpenCode 2 model '${modelSelection.model}' must use provider/model format`, + ); + } + const variant = normalizeOpenCode2Variant( + getModelSelectionStringOptionValue(modelSelection, "variant"), + ); + const agent = resolveOpenCode2SessionAgent( + getModelSelectionStringOptionValue(modelSelection, "agent"), + interactionMode, + knownAgentIDs, + ); + return { + model: { + id: parsed.modelID, + providerID: parsed.providerID, + ...(variant === undefined ? {} : { variant }), + }, + ...(agent === undefined ? {} : { agent }), + }; +} + +/** + * Current 2.x builds replaced the fork body's optional exclusive `messageID` + * with a required `boundary` union: `{type: "before", messageID}` keeps the + * old exclusive semantics and `{type: "through"}` copies the whole head. The + * old shape is rejected with 400 `Missing key at ["boundary"]`. The pinned SDK + * (next-16233, still npm's `next` tag) predates the change and only maps + * `messageID` into the body, so this rides the generated client's `$body_` + * escape hatch to place `boundary` there. + * + * @internal exported for tests + */ +export function openCode2ForkParameters(sessionID: string, boundaryMessageId: string | undefined) { + return { + sessionID, + $body_boundary: + boundaryMessageId === undefined + ? { type: "through" as const } + : { type: "before" as const, messageID: boundaryMessageId }, + }; +} + +/** + * Maps the thread's Build/Plan interaction mode onto OpenCode 2's native + * `build`/`plan` primary agents, mirroring the 1.x adapter's plan fallback. A + * custom agent selection always wins: the toggle only owns the two native + * agents, and the `auto` sentinel (the descriptor default when custom agents + * exist) means "defer to the toggle". Plan dominates a stale explicit + * `build`/`plan` option because every pre-toggle thread has a persisted + * `agent: "build"` selection that would otherwise pin the toggle inert; an + * explicit `plan` option without plan mode still honors plan. With no + * interaction mode (subagent child threads, text generation) the explicit + * option passes through untouched. When the live agent catalog is available, + * a missing agent is omitted so the server can choose a configured default. + * + * @internal exported for tests + */ +export function resolveOpenCode2SessionAgent( + explicitAgent: string | undefined, + interactionMode: ProviderInteractionMode | undefined, + knownAgentIDs?: ReadonlySet | null, +): string | undefined { + const explicit = explicitAgent === OPENCODE2_AUTO_AGENT ? undefined : explicitAgent; + let resolved: string | undefined; + if (explicit !== undefined && explicit !== "build" && explicit !== "plan") { + resolved = explicit; + } else if (interactionMode === undefined) { + resolved = explicit; + } else if (interactionMode === "plan" || explicit === "plan") { + resolved = "plan"; + } else { + resolved = "build"; + } + return resolved === undefined || knownAgentIDs == null || knownAgentIDs.has(resolved) + ? resolved + : undefined; +} + +/** @internal exported for tests */ +export function openCode2InteractionModeForAgent(agent: string): ProviderInteractionMode | null { + if (agent === "build") return "default"; + if (agent === "plan") return "plan"; + return null; +} + +export interface OpenCode2VariantClamp { + readonly variant: string | undefined; + readonly droppedVariant: string | null; +} + +/** + * Fail closed: the server accepts any variant id on session.create and + * session.switchModel but silently drops the next prompt (the user message is + * recorded, no assistant reply ever follows) when the bound variant is not in + * the model's catalog. A variant that cannot be positively validated + * (`knownVariants === null` covers a failed catalog fetch, the empty + * bootstrap catalog, and a model the catalog does not list) is dropped: + * running at the server default is strictly less harmful than a dead turn. + * + * @internal exported for tests + */ +export function clampOpenCode2Variant( + variant: string | undefined, + knownVariants: ReadonlySet | null, +): OpenCode2VariantClamp { + if (variant === undefined) return { variant: undefined, droppedVariant: null }; + if (knownVariants === null || !knownVariants.has(variant)) { + return { variant: undefined, droppedVariant: variant }; + } + return { variant, droppedVariant: null }; +} + +/** + * A freshly spawned 2.x server prints its ready banner before model bootstrap + * finishes and reports an empty catalog until then, exactly when the first + * turn's session.create runs. Without this retry the fail-closed clamp eats a + * valid variant on that first turn (observed live: a Max selection on + * `opencode/glm-5.2` dropped at turn start, bound at server default). Mirrors + * `retryEmptyOpenCode2Inventory`, additionally retrying failed fetches, which + * the clamp represents as `null`. + * + * @internal exported for tests + */ +export const retryEmptyOpenCode2VariantCatalog = Effect.fnUntraced(function* ( + readCatalog: Effect.Effect> | null, E, R>, + options?: { readonly maxAttempts?: number; readonly retryDelayMs?: number }, +) { + const maxAttempts = Math.max(1, options?.maxAttempts ?? 10); + const retryDelayMs = Math.max(0, options?.retryDelayMs ?? 500); + let catalog = yield* readCatalog; + for ( + let attempt = 1; + (catalog === null || catalog.size === 0) && attempt < maxAttempts; + attempt += 1 + ) { + yield* Effect.sleep(retryDelayMs); + catalog = yield* readCatalog; + } + return catalog; +}); + +/** + * A selection with no variant option carries no opinion: subagent child + * threads and pre-variant persisted selections have none, and treating that + * as "reset to default" would clear a variant the native session legitimately + * carries. Only an explicit option expresses intent (the synthetic "default" + * id means reset), and a model change always rebinds. + * + * @internal exported for tests + */ +export function planOpenCode2VariantAlignment(input: { + readonly boundModel: string | null; + readonly boundVariant: string | null; + readonly model: string; + readonly rawVariant: string | undefined; + readonly knownVariants: ReadonlySet | null; +}): OpenCode2VariantClamp & { readonly switchNeeded: boolean } { + const modelChanged = input.boundModel !== input.model; + if (input.rawVariant === undefined && !modelChanged) { + return { switchNeeded: false, variant: undefined, droppedVariant: null }; + } + const clamp = clampOpenCode2Variant( + normalizeOpenCode2Variant(input.rawVariant), + input.knownVariants, + ); + return { + ...clamp, + switchNeeded: modelChanged || input.boundVariant !== (clamp.variant ?? null), + }; +} + +function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string { + const nativeId = providerThread.nativeThreadRef?.nativeId; + if (nativeId === null || nativeId === undefined) { + throw protocolError(`Provider thread ${providerThread.id} has no OpenCode 2 session id`); + } + return nativeId; +} + +function providerRef(nativeId: string, strength: "strong" | "weak" = "strong") { + return { + driver: OPENCODE2_PROVIDER, + nativeId, + strength, + } satisfies OrchestrationV2ProviderRef; +} + +function dateTimeFromEpoch(value: number | undefined, fallback: DateTime.Utc): DateTime.Utc { + if (value === undefined) return fallback; + return Option.getOrElse(DateTime.make(value), () => fallback); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function recordValue(input: unknown, key: string): unknown { + return typeof input === "object" && input !== null && key in input + ? (input as Record)[key] + : undefined; +} + +function recordString(input: unknown, ...keys: ReadonlyArray): string | undefined { + for (const key of keys) { + const value = nonEmptyString(recordValue(input, key)); + if (value !== undefined) return value; + } + return undefined; +} + +function recordStringArray(input: unknown, ...keys: ReadonlyArray): Array { + for (const key of keys) { + const value = recordValue(input, key); + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === "string"); + } + const single = nonEmptyString(value); + if (single !== undefined) return [single]; + } + return []; +} + +function recordNumber(input: unknown, ...keys: ReadonlyArray): number | undefined { + for (const key of keys) { + const value = recordValue(input, key); + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return undefined; +} + +function stableJson(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function sdkResponseForRawLog(value: unknown): unknown { + if (typeof value !== "object" || value === null) return value; + if ("data" in value) return { data: (value as { readonly data?: unknown }).data ?? null }; + if ("stream" in value) return { subscribed: true }; + return value; +} + +/** + * 2.x payloads are double-wrapped: the SDK's `.data` is the parsed body, and + * every body carries its own `data` envelope. Reading one layer yields the + * envelope rather than the value, which fails far from here. + * + * @internal exported for tests + */ +export function unwrapOpenCode2Data( + operation: OpenCode2RuntimeOperation, + result: { readonly data?: { readonly data?: A } | undefined }, +): Effect.Effect, OpenCode2RuntimeError> { + const payload = result.data?.data; + if (payload === undefined || payload === null) { + return Effect.fail( + new OpenCode2RuntimeError({ + operation, + category: "missing-response-payload", + }), + ); + } + return Effect.succeed(payload as NonNullable); +} + +/** @internal exported for tests */ +export function removeOpenCode2Session( + sessionID: string, + request: Effect.Effect, +): Effect.Effect { + return request.pipe( + Effect.flatMap((response) => { + const error = recordValue(response, "error"); + const status = recordNumber(recordValue(response, "response"), "status"); + if (error === undefined || status === 404) return Effect.void; + return Effect.fail( + new OpenCode2RuntimeError({ + operation: "session.remove", + category: "session-remove-failed", + cause: error, + }), + ); + }), + ); +} + +/** + * Stable per-question id so an answer map keyed by header, question text, or + * generated id all resolve. Mirrors `openCodeQuestionId` for the 2.x question + * shape, which carries a header but none of 1.x's other fields. + * + * @internal exported for tests + */ +export function openCode2QuestionId(index: number, header: string): string { + const slug = header + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug.length > 0 ? `question-${index}-${slug}` : `question-${index}`; +} + +/** + * Current 2.x builds surface the question tool through the form API + * (`form.created`, with `metadata.kind: "question"`) instead of + * `question.v2.asked`, which still exists but no longer fires for it. Map a + * form onto the question request shape so the UI renders it unchanged, and + * keep the index-aligned field keys so the reply can address + * `session.form.reply`. Non-question forms get the same treatment: every + * field becomes a question entry. + * + * @internal exported for tests + */ +export function openCode2FormQuestions(form: FormInfo): { + readonly questions: ReadonlyArray; + readonly fieldKeys: ReadonlyArray; + readonly optionValuesByLabel: ReadonlyArray>>; +} { + const questions: Array = []; + const fieldKeys: Array = []; + const optionValuesByLabel: Array>> = []; + for (const field of form.fields) { + const title = field.title?.trim() ?? ""; + const description = field.description?.trim() ?? ""; + const options = "options" in field ? (field.options ?? []) : []; + // The UI answers with labels; the wire wants option values. + questions.push({ + header: title || form.title, + question: description || title || form.title, + options: options.map((option) => ({ + label: option.label.trim() || option.value, + description: option.description?.trim() ?? "", + })), + ...(("custom" in field && field.custom) === true ? { custom: true } : {}), + ...(field.type === "multiselect" ? { multiple: true } : {}), + }); + fieldKeys.push(field.key); + const valuesByLabel = Object.create(null) as Record; + for (const option of options) { + valuesByLabel[option.label.trim() || option.value] = option.value; + } + optionValuesByLabel.push(valuesByLabel); + } + return { questions, fieldKeys, optionValuesByLabel }; +} + +/** + * Builds the `session.form.reply` answer map from per-question answer arrays. + * Answer labels translate back to option values where a mapping exists + * (free-text custom answers pass through), a single selection collapses to + * the plain string a non-multiselect field expects, and unanswered fields are + * omitted rather than sent as empty arrays. + * + * @internal exported for tests + */ +export function openCode2FormAnswer( + fieldKeys: ReadonlyArray, + answers: ReadonlyArray>, + optionValuesByLabel?: ReadonlyArray>>, + multiselectFields?: ReadonlyArray, +): Record> { + const answer = Object.create(null) as Record>; + fieldKeys.forEach((key, index) => { + const valuesByLabel = optionValuesByLabel?.[index]; + const values = (answers[index] ?? []).map((value) => { + if (valuesByLabel === undefined || !Object.hasOwn(valuesByLabel, value)) return value; + return valuesByLabel[value]!; + }); + if (values.length === 0) return; + answer[key] = multiselectFields?.[index] === true || values.length > 1 ? values : values[0]!; + }); + return answer; +} + +/** + * Add T3's per-thread MCP server to a spawned OpenCode 2 process without + * writing the user's global or project configuration. + * + * @internal exported for tests + */ +export const openCode2EnvironmentWithT3Mcp = Effect.fn( + "OpenCode2AdapterV2.openCode2EnvironmentWithT3Mcp", +)(function* (environment: NodeJS.ProcessEnv, session: McpProviderSession.McpProviderSessionConfig) { + const config = yield* decodeOpenCode2InlineConfig(environment.OPENCODE_CONFIG_CONTENT || "{}"); + const mcp = yield* decodeOpenCode2McpConfig(config.mcp ?? {}); + const content = yield* encodeOpenCode2InlineConfig({ + ...config, + mcp: { + ...mcp, + [OPENCODE2_T3_MCP_NAME]: { + type: "remote", + url: session.endpoint, + headers: { Authorization: session.authorizationHeader }, + oauth: false, + }, + }, + }); + return { + ...environment, + OPENCODE_CONFIG_CONTENT: content, + } satisfies NodeJS.ProcessEnv; +}); + +/** + * Give a self-spawned full-access OpenCode 2 server its fixed startup policy. + * + * @internal exported for tests + */ +export const openCode2EnvironmentWithPermission = Effect.fn( + "OpenCode2AdapterV2.openCode2EnvironmentWithPermission", +)(function* ( + environment: NodeJS.ProcessEnv, + runtimePolicy: ProviderAdapterV2TurnInput["runtimePolicy"], +) { + if (!isOpenCodeAllowAllPolicy(runtimePolicy)) return environment; + + const config = yield* decodeOpenCode2InlineConfig(environment.OPENCODE_CONFIG_CONTENT || "{}"); + const content = yield* encodeOpenCode2InlineConfig({ + ...config, + permission: "allow", + }); + return { + ...environment, + OPENCODE_CONFIG_CONTENT: content, + } satisfies NodeJS.ProcessEnv; +}); + +/** + * 2.x has no session-scoped permission ruleset — `session.create` accepts none + * and its native `always` reply persists project-wide — so T3 evaluates each + * request against the same rules used by the 1.x adapter and replies only for + * that request. Session grants are remembered by the adapter instead. + * + * @internal exported for tests + */ +export function openCode2AutoPermissionReply( + runtimePolicy: ProviderAdapterV2TurnInput["runtimePolicy"], + request: { + readonly action: string; + readonly resources: ReadonlyArray; + }, +): "once" | "reject" | null { + const rules = openCodePermissionRules(runtimePolicy); + const resources = request.resources.length === 0 ? ["*"] : request.resources; + let needsApproval = false; + for (const resource of resources) { + const rule = rules.findLast( + (candidate) => + openCode2WildcardMatch(candidate.permission, request.action) && + openCode2WildcardMatch(candidate.pattern, resource), + ); + const effect = rule?.action ?? "ask"; + if (effect === "deny") return "reject"; + if (effect === "ask") needsApproval = true; + } + return needsApproval ? null : "once"; +} + +/** + * OpenCode preview builds may drift from the pinned SDK before its generated + * event types catch up. Keep that drift at the adapter boundary so a missing + * resource or save list cannot terminate the provider event subscription. + * + * @internal exported for tests + */ +export function normalizeOpenCode2PermissionEvent( + protocol: "legacy" | "v2", + data: unknown, +): { + readonly action: string; + readonly resources: ReadonlyArray; + readonly save: ReadonlyArray; +} { + return { + action: + (protocol === "legacy" + ? recordString(data, "permission", "action") + : recordString(data, "action", "permission")) ?? "unknown", + resources: + protocol === "legacy" + ? recordStringArray(data, "patterns", "resources", "pattern") + : recordStringArray(data, "resources", "patterns", "pattern"), + save: + protocol === "legacy" + ? recordStringArray(data, "always", "save") + : recordStringArray(data, "save", "always"), + }; +} + +export function openCode2PermissionAutoReply( + runtimePolicy: ProviderAdapterV2TurnInput["runtimePolicy"], + sessionPermissions: ReadonlyArray, + request: { + readonly action: string; + readonly resources: ReadonlyArray; + }, +): "once" | "reject" | null { + const resources = request.resources.length === 0 ? ["*"] : request.resources; + let needsApproval = false; + for (const resource of resources) { + const resourceRequest = { action: request.action, resources: [resource] }; + const policyReply = openCode2AutoPermissionReply(runtimePolicy, resourceRequest); + if (policyReply === "reject") return "reject"; + if (policyReply === "once") continue; + if ( + sessionPermissions.some((permission) => + openCode2SessionPermissionMatches(permission, resourceRequest), + ) + ) { + continue; + } + needsApproval = true; + } + return needsApproval ? null : "once"; +} + +/** @internal exported for tests */ +export function openCode2PermissionAutoReplyForSession( + runtimePolicy: ProviderAdapterV2TurnInput["runtimePolicy"], + sessionPermissions: OpenCode2SessionPermissionStore, + nativeSessionId: string, + request: { + readonly action: string; + readonly resources: ReadonlyArray; + }, +): "once" | "reject" | null { + return openCode2PermissionAutoReply( + runtimePolicy, + sessionPermissions.get(nativeSessionId) ?? [], + request, + ); +} + +function openCode2WildcardMatch(pattern: string, value: string): boolean { + if (pattern === "*") return true; + const expression = pattern + .split("*") + .map((part) => part.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&")) + .join(".*"); + return new RegExp(`^${expression}$`).test(value); +} + +function openCode2SessionPermissionMatches( + permission: OpenCode2SessionPermission, + request: { + readonly action: string; + readonly resources: ReadonlyArray; + }, +): boolean { + if (!openCode2WildcardMatch(permission.action, request.action)) return false; + const resources = request.resources.length === 0 ? ["*"] : request.resources; + return resources.every((resource) => + permission.resources.some((pattern) => openCode2WildcardMatch(pattern, resource)), + ); +} + +function isOpenCodeAllowAllPolicy( + runtimePolicy: ProviderAdapterV2TurnInput["runtimePolicy"], +): boolean { + const rules = openCodePermissionRules(runtimePolicy); + return ( + rules.length === 1 && + rules[0]?.permission === "*" && + rules[0].pattern === "*" && + rules[0].action === "allow" + ); +} + +/** @internal exported for tests */ +export function rememberOpenCode2SessionPermission( + permissionsBySession: OpenCode2SessionPermissionStore, + nativeSessionId: string, + permission: PendingOpenCode2Request["permission"], +): void { + if (permission === undefined) return; + const permissions = permissionsBySession.get(nativeSessionId) ?? []; + const savedResources = permission.save.length === 0 ? permission.resources : permission.save; + const remembered = { + action: permission.action, + resources: savedResources.length === 0 ? ["*"] : savedResources, + }; + if ( + permissions.some( + (existing) => + existing.action === remembered.action && + existing.resources.length === remembered.resources.length && + existing.resources.every((resource, index) => resource === remembered.resources[index]), + ) + ) { + return; + } + permissions.push(remembered); + permissionsBySession.set(nativeSessionId, permissions); +} + +/** @internal exported for tests */ +export function openCode2ChildTurnItemOrdinals(providerTurnOrdinal: number): { + readonly next: number; + readonly user: number; +} { + const user = providerTurnOrdinal * 100; + return { next: user + 1, user }; +} + +function toOpenCode2FileAttachments(input: { + readonly attachments: ReadonlyArray | undefined; + readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; +}): Array { + const files: Array = []; + for (const attachment of input.attachments ?? []) { + const attachmentPath = input.resolveAttachmentPath(attachment); + if (!attachmentPath) continue; + files.push({ + uri: NodeURL.pathToFileURL(attachmentPath).href, + ...(attachment.name ? { name: attachment.name } : {}), + }); + } + return files; +} + +function toolNodeStatus(status: OpenCode2ToolStatus): { + readonly node: OrchestrationV2ExecutionNode["status"]; + readonly item: OrchestrationV2TurnItem["status"]; +} { + switch (status) { + case "pending": + return { node: "pending", item: "pending" }; + case "running": + return { node: "running", item: "running" }; + case "completed": + return { node: "completed", item: "completed" }; + case "error": + return { node: "failed", item: "failed" }; + } +} + +function subagentStatusFromTurnItemStatus( + status: OrchestrationV2TurnItem["status"], +): OpenCode2SubagentContext["status"] { + switch (status) { + case "failed": + return "failed"; + case "completed": + return "completed"; + case "cancelled": + return "cancelled"; + case "interrupted": + return "interrupted"; + case "pending": + return "pending"; + default: + return "running"; + } +} + +function compactionStatusFromTerminalTurnStatus( + status: TerminalTurnStatus, +): OpenCode2Compaction["status"] { + switch (status) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "cancelled": + case "interrupted": + return "cancelled"; + } +} + +function compactionTitle(status: OpenCode2Compaction["status"]): string { + switch (status) { + case "running": + return "Compacting context..."; + case "completed": + return "Context compacted"; + case "failed": + return "Context compaction failed"; + case "cancelled": + return "Context compaction stopped"; + } +} + +function toolContentText(content: unknown): string | undefined { + if (!Array.isArray(content)) return undefined; + const chunks = content + .filter((entry) => recordString(entry, "type") === "text") + .map((entry) => recordString(entry, "text")) + .filter((text): text is string => text !== undefined); + return chunks.length === 0 ? undefined : chunks.join("\n"); +} + +function openCode2PermissionRequestKind(action: string): ProviderRequestKind { + return openCodePermissionRequestKind(action); +} + +function makeProviderThread(input: { + readonly idAllocator: IdAllocatorV2Shape; + readonly providerInstanceId: ProviderInstanceId; + readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; + readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; + readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; + readonly nativeSession: SessionInfoV2; + readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; + readonly now: DateTime.Utc; +}): OrchestrationV2ProviderThread { + return { + id: input.idAllocator.derive.providerThread({ + driver: OPENCODE2_PROVIDER, + nativeThreadId: input.nativeSession.id, + }), + driver: OPENCODE2_PROVIDER, + providerInstanceId: input.providerInstanceId, + providerSessionId: input.providerSessionId, + appThreadId: input.appThreadId, + ownerNodeId: input.ownerNodeId ?? null, + nativeThreadRef: { + driver: OPENCODE2_PROVIDER, + nativeId: input.nativeSession.id, + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: input.forkedFrom ?? null, + createdAt: dateTimeFromEpoch(input.nativeSession.time.created, input.now), + updatedAt: dateTimeFromEpoch(input.nativeSession.time.updated, input.now), + }; +} + +export function makeOpenCode2AdapterV2(options: OpenCode2AdapterV2Options): ProviderAdapterV2Shape { + const { idAllocator, runtime, serverConfig } = options; + const continuationRequests = options.continuationRequests; + const interactionModeReflections = options.interactionModeReflections; + + return ProviderAdapterV2.of({ + instanceId: options.instanceId, + driver: OPENCODE2_PROVIDER, + deleteDetachedThread: (input) => + Effect.gen(function* () { + const sessionID = nativeThreadId(input.providerThread); + const connection = yield* runtime.connectToOpenCode2Server({ + binaryPath: options.settings.binaryPath, + serverUrl: options.settings.serverUrl, + serverPassword: options.settings.serverPassword, + environment: options.environment, + }); + const client = runtime.createOpenCode2SdkClient({ + baseUrl: connection.url, + directory: input.providerSession.cwd, + serverPassword: connection.password, + }); + yield* removeOpenCode2Session( + sessionID, + runOpenCode2Sdk("session.remove", () => + client.v2.session.remove({ sessionID }, { throwOnError: false }), + ), + ); + }).pipe( + Effect.mapError((cause) => + protocolError( + `Failed to delete detached OpenCode 2 session ${input.providerThread.id}`, + cause, + ), + ), + ), + getCapabilities: () => Effect.succeed(OpenCode2ProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), + openSession: Effect.fn("OpenCode2AdapterV2.openSession")( + function* (input: ProviderAdapterV2OpenSessionInput) { + const scope = yield* Effect.scope; + const cwd = input.runtimePolicy.cwd ?? serverConfig.cwd; + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const selfSpawning = !(options.settings.serverUrl?.trim() ?? ""); + const hasT3Mcp = mcpSession !== undefined && selfSpawning; + const environmentWithMcp = + hasT3Mcp && mcpSession !== undefined + ? yield* openCode2EnvironmentWithT3Mcp(options.environment, mcpSession) + : options.environment; + // The injected policy is fixed at spawn, like 1.x's session.create + // ruleset. A stricter mid-thread turn does not re-gate asks suppressed + // by allow-all until the provider session is reopened. + const injectedAllowPolicy = selfSpawning && isOpenCodeAllowAllPolicy(input.runtimePolicy); + const environment = injectedAllowPolicy + ? yield* openCode2EnvironmentWithPermission(environmentWithMcp, input.runtimePolicy) + : environmentWithMcp; + const connection = yield* runtime.connectToOpenCode2Server({ + binaryPath: options.settings.binaryPath, + serverUrl: options.settings.serverUrl, + serverPassword: options.settings.serverPassword, + environment, + }); + const spawnedWithInjectedAllowPolicy = injectedAllowPolicy && !connection.external; + let warnedAboutInjectedAllowPolicy = false; + const client = runtime.createOpenCode2SdkClient({ + baseUrl: connection.url, + directory: cwd, + serverPassword: connection.password, + }); + + const now = yield* DateTime.now; + let sessionEntity: OrchestrationV2ProviderSession = { + id: input.providerSessionId, + driver: OPENCODE2_PROVIDER, + providerInstanceId: options.instanceId, + status: "ready", + cwd, + model: input.modelSelection.model, + capabilities: OpenCode2ProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }; + const events = yield* Queue.unbounded(); + const threads = new Map(); + const shellProjections = new Map(); + const shellSessionIds = new Map(); + const pendingRequests = new Map(); + const pendingRequestsByNativeId = new Map(); + const subagentsByNativeItemId = new Map(); + const subagentsByChildSessionId = new Map(); + const nativeChildSessions = new Map< + string, + Extract["data"]["info"] + >(); + const sessionPermissions: OpenCode2SessionPermissionStore = new Map(); + const abortController = new AbortController(); + + const emitProviderEvent = (event: ProviderAdapterV2Event) => + Queue.offer(events, event).pipe(Effect.asVoid); + + const logProtocolEvent = makeOpenCode2ProtocolLogger({ + nativeEventLogger: options.nativeEventLogger, + idAllocator, + providerInstanceId: options.instanceId, + providerSessionId: input.providerSessionId, + threadId: input.threadId, + }); + + const sdkCall = ( + method: OpenCode2RuntimeOperation, + payload: unknown, + call: () => Promise, + ): Effect.Effect => + logProtocolEvent({ + direction: "outgoing", + messageKind: "request", + method, + payload, + }).pipe( + Effect.andThen(runOpenCode2Sdk(method, call)), + Effect.tap((response) => + logProtocolEvent({ + direction: "incoming", + messageKind: "response", + method, + payload: sdkResponseForRawLog(response), + }), + ), + ); + + const updateProviderSession = ( + status: OrchestrationV2ProviderSession["status"], + lastError: string | null = sessionEntity.lastError, + ) => + Effect.gen(function* () { + const updatedAt = yield* DateTime.now; + sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; + yield* emitProviderEvent({ + type: "provider_session.updated", + driver: OPENCODE2_PROVIDER, + providerSession: sessionEntity, + }); + }); + + const updateProviderThread = ( + state: OpenCode2ThreadState, + patch: Partial, + ) => + Effect.gen(function* () { + const updatedAt = yield* DateTime.now; + state.providerThread = { ...state.providerThread, ...patch, updatedAt }; + yield* emitProviderEvent({ + type: "provider_thread.updated", + driver: OPENCODE2_PROVIDER, + providerThread: state.providerThread, + }); + }); + + const itemOrdinal = (turn: ActiveOpenCode2Turn, nativeItemId: string): number => { + const existing = turn.itemOrdinals.get(nativeItemId); + if (existing !== undefined) return existing; + const ordinal = turn.nextItemOrdinal++; + turn.itemOrdinals.set(nativeItemId, ordinal); + return ordinal; + }; + + const emitProviderTurn = ( + state: OpenCode2ThreadState, + turn: ActiveOpenCode2Turn, + status: OrchestrationV2ProviderTurn["status"], + completedAt: DateTime.Utc | null, + ) => { + const providerTurn: OrchestrationV2ProviderTurn = { + ...turn.providerTurn, + nativeTurnRef: + turn.nativeInputId === null + ? turn.providerTurn.nativeTurnRef + : providerRef(turn.nativeInputId, "weak"), + status, + completedAt, + }; + Object.assign(turn.providerTurn, providerTurn); + state.providerTurns.set(String(providerTurn.id), providerTurn); + return emitProviderEvent({ + type: "provider_turn.updated", + driver: OPENCODE2_PROVIDER, + threadId: turn.threadId, + providerTurn, + }); + }; + + const emitTextPart = Effect.fnUntraced(function* ( + state: OpenCode2ThreadState, + turn: ActiveOpenCode2Turn, + part: OpenCode2TextPart, + forceCompleted = false, + ) { + if (part.text.length === 0) return; + const emittedAt = yield* DateTime.now; + const isCompleted = forceCompleted || part.completed; + const completedAt = isCompleted ? emittedAt : null; + const nativeItemRef = providerRef(part.id); + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: OPENCODE2_PROVIDER, + nativeItemId: part.id, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: OPENCODE2_PROVIDER, + nativeItemId: part.id, + }); + const ordinal = itemOrdinal(turn, part.id); + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE2_PROVIDER, + node: { + id: nodeId, + threadId: turn.threadId, + runId: turn.runId, + parentNodeId: turn.rootNodeId, + rootNodeId: turn.rootNodeId, + kind: part.kind === "text" ? "assistant_message" : "reasoning", + status: isCompleted ? "completed" : "running", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: part.startedAt, + completedAt, + }, + }); + if (part.kind === "text") { + const messageId = idAllocator.derive.messageFromProviderItem({ + driver: OPENCODE2_PROVIDER, + nativeItemId: part.id, + }); + const message: OrchestrationV2ConversationMessage = { + createdBy: "agent", + creationSource: "provider", + id: messageId, + threadId: turn.threadId, + runId: turn.runId, + nodeId, + role: "assistant", + text: part.text, + attachments: [], + streaming: !isCompleted, + createdAt: part.startedAt, + updatedAt: emittedAt, + }; + state.messages.set(String(message.id), message); + yield* emitProviderEvent({ + type: "message.updated", + driver: OPENCODE2_PROVIDER, + message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE2_PROVIDER, + turnItem: { + id: turnItemId, + threadId: turn.threadId, + runId: turn.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status: isCompleted ? "completed" : "running", + title: null, + startedAt: part.startedAt, + completedAt, + updatedAt: emittedAt, + type: "assistant_message", + messageId, + text: part.text, + streaming: !isCompleted, + }, + }); + return; + } + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE2_PROVIDER, + turnItem: { + id: turnItemId, + threadId: turn.threadId, + runId: turn.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status: isCompleted ? "completed" : "running", + title: null, + startedAt: part.startedAt, + completedAt, + updatedAt: emittedAt, + type: "reasoning", + text: part.text, + streaming: !isCompleted, + }, + }); + }); + + const bindSubagentChild = Effect.fnUntraced(function* ( + context: OpenCode2SubagentContext, + nativeSession: Extract["data"]["info"], + ) { + if (context.childSessionId !== null) return; + const now = yield* DateTime.now; + const childSessionId = nativeSession.id; + const childThreadId = idAllocator.derive.threadFromProviderThread({ + driver: OPENCODE2_PROVIDER, + nativeThreadId: childSessionId, + }); + const childProviderThreadId = idAllocator.derive.providerThread({ + driver: OPENCODE2_PROVIDER, + nativeThreadId: childSessionId, + }); + const model = + nativeSession.model === undefined + ? context.model + : `${nativeSession.model.providerID}/${nativeSession.model.id}`; + const childVariant = normalizeOpenCode2Variant(nativeSession.model?.variant); + const childModelSelection: ModelSelection = { + instanceId: options.instanceId, + model: model ?? context.parentTurn.modelSelection.model, + ...(childVariant === undefined + ? {} + : { options: [{ id: "variant", value: childVariant }] }), + }; + const childThread = makeSubagentChildThread({ + parentThread: context.parentTurn.appThread, + childThreadId, + parentNodeId: context.nodeId, + activeProviderThreadId: childProviderThreadId, + providerInstanceId: options.instanceId, + modelSelection: childModelSelection, + title: subagentThreadTitle({ + parentTitle: context.parentTurn.appThread.title, + title: context.title ?? nativeSession.title, + prompt: context.prompt, + ordinal: itemOrdinal(context.parentTurn, context.nativeItemId), + }), + now, + createdBy: "agent", + creationSource: "provider", + }); + const childProviderThread: OrchestrationV2ProviderThread = { + id: childProviderThreadId, + driver: OPENCODE2_PROVIDER, + providerInstanceId: options.instanceId, + providerSessionId: input.providerSessionId, + appThreadId: childThreadId, + ownerNodeId: context.nodeId, + nativeThreadRef: providerRef(childSessionId), + nativeConversationHeadRef: null, + status: "active", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: dateTimeFromEpoch(nativeSession.time.created, now), + updatedAt: dateTimeFromEpoch(nativeSession.time.updated, now), + }; + context.childSessionId = childSessionId; + context.childThreadId = childThreadId; + context.childProviderThreadId = childProviderThreadId; + context.model = model; + context.status = "pending"; + context.completedAt = null; + context.progress = undefined; + context.result = null; + subagentsByChildSessionId.set(childSessionId, context); + threads.set(childSessionId, { + nativeSessionId: childSessionId, + location: context.parentState.location, + providerThread: childProviderThread, + appThread: childThread, + activeTurn: null, + boundModel: model, + boundVariant: normalizeOpenCode2Variant(nativeSession.model?.variant) ?? null, + boundAgent: nativeSession.agent ?? null, + lastAgentSelectedEventId: null, + providerTurns: new Map(), + messages: new Map(), + runtimeRequests: new Map(), + postSettleWakes: [], + retiredSuppressWakes: new Map(), + promotedInputIds: new Set(), + sawInputPromotion: false, + activeExecution: null, + parentSubagent: context, + nextChildTurnOrdinal: 1, + }); + yield* emitProviderEvent({ + type: "app_thread.created", + driver: OPENCODE2_PROVIDER, + appThread: childThread, + }); + yield* emitProviderEvent({ + type: "provider_thread.updated", + driver: OPENCODE2_PROVIDER, + providerThread: childProviderThread, + }); + }); + + const emitSubagentContext = Effect.fnUntraced(function* ( + context: OpenCode2SubagentContext, + ) { + const now = yield* DateTime.now; + const terminal = + context.status === "completed" || + context.status === "failed" || + context.status === "cancelled" || + context.status === "interrupted"; + if (terminal && context.completedAt === null) context.completedAt = now; + const nodeStatus: OrchestrationV2ExecutionNode["status"] = context.status; + const subagent: OrchestrationV2Subagent = { + id: context.nodeId, + threadId: context.parentTurn.threadId, + runId: context.parentTurn.runId, + parentNodeId: context.parentTurn.rootNodeId, + origin: "provider_native", + createdBy: "agent", + driver: OPENCODE2_PROVIDER, + providerInstanceId: options.instanceId, + providerThreadId: context.childProviderThreadId, + childThreadId: context.childThreadId, + nativeTaskRef: providerRef(context.nativeItemId), + prompt: context.prompt, + title: context.title, + model: context.model, + status: context.status, + ...(context.progress === undefined ? {} : { progress: context.progress }), + result: context.result, + startedAt: context.startedAt, + completedAt: context.completedAt, + updatedAt: now, + }; + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE2_PROVIDER, + node: { + id: context.nodeId, + threadId: context.parentTurn.threadId, + runId: context.parentTurn.runId, + parentNodeId: context.parentTurn.rootNodeId, + rootNodeId: context.parentTurn.rootNodeId, + kind: "subagent", + status: nodeStatus, + countsForRun: false, + providerThreadId: + context.childProviderThreadId ?? context.parentState.providerThread.id, + providerTurnId: context.parentTurn.providerTurnId, + nativeItemRef: providerRef(context.nativeItemId), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: context.startedAt, + completedAt: context.completedAt, + }, + }); + yield* emitProviderEvent({ + type: "subagent.updated", + driver: OPENCODE2_PROVIDER, + subagent, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE2_PROVIDER, + turnItem: { + id: context.turnItemId, + threadId: context.parentTurn.threadId, + runId: context.parentTurn.runId, + nodeId: context.nodeId, + providerThreadId: context.parentState.providerThread.id, + providerTurnId: context.parentTurn.providerTurnId, + nativeItemRef: providerRef(context.nativeItemId), + parentItemId: null, + ordinal: itemOrdinal(context.parentTurn, context.nativeItemId), + status: context.status, + title: context.title, + startedAt: context.startedAt, + completedAt: context.completedAt, + updatedAt: now, + type: "subagent", + subagentId: context.nodeId, + origin: "provider_native", + driver: OPENCODE2_PROVIDER, + providerInstanceId: options.instanceId, + childThreadId: context.childThreadId, + prompt: context.prompt, + result: context.result, + }, + }); + }); + + const emitSubagent = Effect.fnUntraced(function* ( + state: OpenCode2ThreadState, + turn: ActiveOpenCode2Turn, + part: OpenCode2ToolPart, + terminal?: TerminalTurnStatus, + ) { + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: OPENCODE2_PROVIDER, + nativeItemId: part.id, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: OPENCODE2_PROVIDER, + nativeItemId: part.id, + }); + let context = subagentsByNativeItemId.get(part.id); + if (context === undefined) { + context = { + nativeItemId: part.id, + nodeId, + turnItemId, + parentState: state, + parentTurn: turn, + startedAt: part.startedAt, + completedAt: null, + prompt: recordString(part.input, "prompt") ?? "", + title: recordString(part.input, "description") ?? null, + model: recordString(part.input, "model") ?? null, + childSessionId: null, + childThreadId: null, + childProviderThreadId: null, + status: "pending", + progress: undefined, + result: null, + }; + subagentsByNativeItemId.set(part.id, context); + } + context.prompt = recordString(part.input, "prompt") ?? context.prompt; + context.title = recordString(part.input, "description") ?? context.title; + context.model = recordString(part.input, "model") ?? context.model; + const isBackgroundLaunch = + recordValue(part.input, "background") === true && part.status === "completed"; + if (context.childSessionId === null && !isBackgroundLaunch) { + const status = + terminal === undefined ? toolNodeStatus(part.status) : terminalToolStatus(terminal); + context.status = subagentStatusFromTurnItemStatus(status.item); + if (context.status !== "running") context.progress = undefined; + if (part.output !== undefined && context.status === "completed") { + context.result = part.output; + } else if (context.status === "failed") { + context.result = part.errorMessage ?? part.output ?? context.result; + } + } else if (isBackgroundLaunch && context.childSessionId === null) { + context.status = "running"; + context.progress = part.output; + } + + if (context.childSessionId === null) { + const matchingChild = Array.from(nativeChildSessions.values()).find( + (candidate) => + candidate.parentID === state.nativeSessionId && + !subagentsByChildSessionId.has(candidate.id) && + (context.title === null || candidate.title === context.title), + ); + if (matchingChild !== undefined) yield* bindSubagentChild(context, matchingChild); + } + yield* emitSubagentContext(context); + }); + + const emitToolPart = Effect.fnUntraced(function* ( + state: OpenCode2ThreadState, + turn: ActiveOpenCode2Turn, + part: OpenCode2ToolPart, + /** + * Force a terminal status for a tool the turn ended underneath. + * `session.interrupt` stops the execution without reporting a final + * state for whatever tool was mid-flight, so the last observed + * status stays `running` and the row would spin forever. + */ + terminal?: TerminalTurnStatus, + ) { + if (part.name.toLowerCase() === "subagent") { + yield* emitSubagent(state, turn, part, terminal); + return; + } + const emittedAt = yield* DateTime.now; + const status = + terminal === undefined ? toolNodeStatus(part.status) : terminalToolStatus(terminal); + const completedAt = + terminal === undefined ? part.completedAt : (part.completedAt ?? emittedAt); + const nativeItemRef = providerRef(part.id); + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: OPENCODE2_PROVIDER, + nativeItemId: part.id, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: OPENCODE2_PROVIDER, + nativeItemId: part.id, + }); + const base = { + id: turnItemId, + threadId: turn.threadId, + runId: turn.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal: itemOrdinal(turn, part.id), + status: status.item, + title: part.name, + startedAt: part.startedAt, + completedAt, + updatedAt: emittedAt, + } satisfies Pick< + OrchestrationV2TurnItem, + | "id" + | "threadId" + | "runId" + | "nodeId" + | "providerThreadId" + | "providerTurnId" + | "nativeItemRef" + | "parentItemId" + | "ordinal" + | "status" + | "title" + | "startedAt" + | "completedAt" + | "updatedAt" + >; + const projectionKind = openCodeToolProjectionKind(part.name); + const exitCode = recordNumber(part.structured, "exit", "exitCode"); + let turnItem: OrchestrationV2TurnItem; + if (projectionKind === "command_execution") { + turnItem = { + ...base, + type: "command_execution", + input: recordString(part.input, "command", "cmd") ?? stableJson(part.input), + ...(part.output === undefined ? {} : { output: part.output }), + ...(exitCode === undefined ? {} : { exitCode }), + }; + } else if (projectionKind === "file_change") { + turnItem = { + ...base, + type: "file_change", + fileName: recordString(part.input, "filePath", "path", "file") ?? part.name, + ...(recordString(part.input, "oldString", "oldText") === undefined + ? {} + : { oldStr: recordString(part.input, "oldString", "oldText")! }), + ...(recordString(part.input, "newString", "content", "newText") === undefined + ? {} + : { newStr: recordString(part.input, "newString", "content", "newText")! }), + ...(recordString(part.structured, "diff", "patch") === undefined + ? {} + : { diffStr: recordString(part.structured, "diff", "patch")! }), + }; + } else if (projectionKind === "file_search") { + turnItem = { + ...base, + type: "file_search", + ...(recordString(part.input, "pattern", "query", "path", "filePath") === undefined + ? {} + : { pattern: recordString(part.input, "pattern", "query", "path", "filePath")! }), + }; + } else if (projectionKind === "web_search") { + const pattern = recordString(part.input, "query", "url", "pattern"); + turnItem = { + ...base, + type: "web_search", + ...(pattern === undefined ? {} : { patterns: [pattern] }), + }; + } else { + turnItem = { + ...base, + type: "dynamic_tool", + toolName: part.name, + input: part.input, + ...(part.output === undefined ? {} : { output: part.output }), + }; + } + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE2_PROVIDER, + node: { + id: nodeId, + threadId: turn.threadId, + runId: turn.runId, + parentNodeId: turn.rootNodeId, + rootNodeId: turn.rootNodeId, + kind: "tool_call", + status: status.node, + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: part.startedAt, + completedAt, + }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE2_PROVIDER, + turnItem, + }); + }); + + const emitCompaction = Effect.fnUntraced(function* ( + state: OpenCode2ThreadState, + turn: ActiveOpenCode2Turn, + compaction: OpenCode2Compaction, + ) { + const emittedAt = yield* DateTime.now; + const completedAt = + compaction.status === "running" ? null : (compaction.completedAt ?? emittedAt); + const nativeItemRef = providerRef(compaction.id); + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: OPENCODE2_PROVIDER, + nativeItemId: compaction.id, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: OPENCODE2_PROVIDER, + nativeItemId: compaction.id, + }); + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE2_PROVIDER, + node: { + id: nodeId, + threadId: turn.threadId, + runId: turn.runId, + parentNodeId: turn.rootNodeId, + rootNodeId: turn.rootNodeId, + kind: "system", + status: compaction.status, + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: compaction.startedAt, + completedAt, + }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE2_PROVIDER, + turnItem: { + id: turnItemId, + threadId: turn.threadId, + runId: turn.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal: itemOrdinal(turn, compaction.id), + status: compaction.status, + title: compactionTitle(compaction.status), + startedAt: compaction.startedAt, + completedAt, + updatedAt: emittedAt, + type: "compaction", + driver: OPENCODE2_PROVIDER, + ...(compaction.summary.length === 0 ? {} : { summary: compaction.summary }), + }, + }); + }); + + const runningShellForPart = ( + turn: ActiveOpenCode2Turn, + part: OpenCode2ToolPart, + ): OpenCode2ShellProjection | undefined => + Array.from(shellProjections.values()).find( + (shell) => shell.turn === turn && shell.part === part && shell.status === "running", + ); + + const runtimeRequestTurnItem = ( + pending: PendingOpenCode2Request, + status: OrchestrationV2TurnItem["status"], + completedAt: DateTime.Utc | null, + updatedAt: DateTime.Utc, + ): OrchestrationV2TurnItem => { + const base = { + id: pending.turnItemId, + threadId: pending.turn.threadId, + runId: pending.turn.runId, + nodeId: pending.nodeId, + providerThreadId: pending.state.providerThread.id, + providerTurnId: pending.turn.providerTurnId, + nativeItemRef: providerRef(pending.nativeRequestId), + parentItemId: null, + ordinal: itemOrdinal(pending.turn, pending.nativeRequestId), + status, + startedAt: pending.createdAt, + completedAt, + updatedAt, + }; + if (pending.questions !== undefined) { + return { + ...base, + title: "User input", + type: "user_input_request", + requestId: pending.requestId, + questions: pending.questions.map((question, index) => ({ + id: openCode2QuestionId(index, question.header), + header: question.header.trim() || `Question ${index + 1}`, + question: + question.question.trim() || question.header.trim() || `Question ${index + 1}`, + options: question.options.map((option) => ({ + label: option.label.trim() || "Option", + description: option.description.trim() || option.label.trim() || "Option", + })), + multiSelect: question.multiple === true, + })), + }; + } + const permission = pending.permission; + if (permission === undefined) { + throw protocolError(`OpenCode 2 request ${pending.requestId} has no native payload`); + } + return { + ...base, + title: permission.action, + type: "approval_request", + requestId: pending.requestId, + requestKind: + pending.requestKind === "user_input" + ? "command" + : (pending.requestKind as Exclude), + prompt: + permission.resources.length === 0 + ? permission.action + : permission.resources.join("\n"), + }; + }; + + const emitRuntimeRequest = Effect.fnUntraced(function* ( + state: OpenCode2ThreadState, + turn: ActiveOpenCode2Turn, + nativeSessionId: string, + nativeRequestId: string, + request: + | { + readonly type: "permission"; + readonly action: string; + readonly resources: ReadonlyArray; + readonly save: ReadonlyArray; + } + | { + readonly type: "question"; + readonly questions: ReadonlyArray; + readonly formFieldKeys?: ReadonlyArray; + readonly formOptionValues?: ReadonlyArray>>; + }, + ) { + if (pendingRequestsByNativeId.has(nativeRequestId)) return; + const createdAt = yield* DateTime.now; + const requestId = yield* idAllocator.allocate.runtimeRequest({ + driver: OPENCODE2_PROVIDER, + providerTurnId: turn.providerTurnId, + nativeRequestId, + }); + const nodeId = idAllocator.derive.approvalNode({ requestId }); + const turnItemId = idAllocator.derive.approvalTurnItem({ requestId }); + const requestKind: OrchestrationV2RuntimeRequest["kind"] = + request.type === "permission" + ? openCode2PermissionRequestKind(request.action) + : "user_input"; + const pendingBase = { + requestId, + nativeRequestId, + nativeSessionId, + turn, + state, + nodeId, + turnItemId, + requestKind, + createdAt, + }; + let pending: PendingOpenCode2Request; + if (request.type === "permission") { + pending = { + ...pendingBase, + permission: { + action: request.action, + resources: request.resources, + save: request.save, + }, + }; + } else { + pending = { + ...pendingBase, + questions: request.questions, + ...(request.formFieldKeys === undefined + ? {} + : { formFieldKeys: request.formFieldKeys }), + ...(request.formOptionValues === undefined + ? {} + : { formOptionValues: request.formOptionValues }), + }; + } + pendingRequests.set(String(requestId), pending); + pendingRequestsByNativeId.set(nativeRequestId, pending); + const runtimeRequest: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId: turn.providerTurnId, + nativeRequestRef: providerRef(nativeRequestId), + kind: requestKind, + status: "pending", + responseCapability: { + type: "live", + providerSessionId: input.providerSessionId, + }, + createdAt, + resolvedAt: null, + }; + state.runtimeRequests.set(String(requestId), runtimeRequest); + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE2_PROVIDER, + node: { + id: nodeId, + threadId: turn.threadId, + runId: turn.runId, + parentNodeId: turn.rootNodeId, + rootNodeId: turn.rootNodeId, + kind: request.type === "question" ? "user_input_request" : "approval_request", + status: "waiting", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef: providerRef(nativeRequestId), + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: createdAt, + completedAt: null, + }, + }); + yield* emitProviderEvent({ + type: "runtime_request.updated", + driver: OPENCODE2_PROVIDER, + threadId: turn.threadId, + runtimeRequest, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE2_PROVIDER, + turnItem: runtimeRequestTurnItem(pending, "waiting", null, createdAt), + }); + yield* updateProviderSession("waiting", null); + }); + + const resolveRuntimeRequest = Effect.fnUntraced(function* ( + nativeRequestId: string, + status: "resolved" | "cancelled", + ) { + const pending = pendingRequestsByNativeId.get(nativeRequestId); + if (pending === undefined) return; + const resolvedAt = yield* DateTime.now; + const current = pending.state.runtimeRequests.get(String(pending.requestId)); + if (current !== undefined) { + const resolved: OrchestrationV2RuntimeRequest = { ...current, status, resolvedAt }; + pending.state.runtimeRequests.set(String(pending.requestId), resolved); + yield* emitProviderEvent({ + type: "runtime_request.updated", + driver: OPENCODE2_PROVIDER, + threadId: pending.turn.threadId, + runtimeRequest: resolved, + }); + } + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE2_PROVIDER, + node: { + id: pending.nodeId, + threadId: pending.turn.threadId, + runId: pending.turn.runId, + parentNodeId: pending.turn.rootNodeId, + rootNodeId: pending.turn.rootNodeId, + kind: pending.questions === undefined ? "approval_request" : "user_input_request", + status: status === "resolved" ? "completed" : "cancelled", + countsForRun: false, + providerThreadId: pending.state.providerThread.id, + providerTurnId: pending.turn.providerTurnId, + nativeItemRef: providerRef(nativeRequestId), + runtimeRequestId: pending.requestId, + checkpointScopeId: null, + startedAt: pending.createdAt, + completedAt: resolvedAt, + }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE2_PROVIDER, + turnItem: runtimeRequestTurnItem( + pending, + status === "resolved" ? "completed" : "cancelled", + resolvedAt, + resolvedAt, + ), + }); + pendingRequests.delete(String(pending.requestId)); + pendingRequestsByNativeId.delete(nativeRequestId); + if (pendingRequests.size === 0) yield* updateProviderSession("running", null); + }); + + const finalizeTurn = Effect.fnUntraced(function* ( + state: OpenCode2ThreadState, + turn: ActiveOpenCode2Turn, + status: TerminalTurnStatus, + terminal?: { + readonly failure?: OrchestrationV2ProviderFailure; + readonly threadDisposition?: "reusable" | "broken"; + }, + ) { + if (turn.finalized) return; + turn.finalized = true; + const completedAt = yield* DateTime.now; + for (const part of turn.parts.values()) { + if (part.kind === "tool") { + const subagent = subagentsByNativeItemId.get(part.id); + const childTurn = + subagent?.childSessionId === null || subagent?.childSessionId === undefined + ? null + : threads.get(subagent.childSessionId)?.activeTurn; + if ( + part.name.toLowerCase() === "subagent" && + childTurn !== null && + childTurn !== undefined && + !childTurn.finalized + ) { + continue; + } + if (status === "completed" && runningShellForPart(turn, part) !== undefined) { + continue; + } + if (openCode2ToolNeedsTerminalOverride(part, status)) { + yield* emitToolPart(state, turn, part, status); + } + continue; + } + yield* emitTextPart(state, turn, part, true); + } + if (turn.activeCompaction?.status === "running") { + turn.activeCompaction.status = compactionStatusFromTerminalTurnStatus(status); + turn.activeCompaction.completedAt = completedAt; + yield* emitCompaction(state, turn, turn.activeCompaction); + } + for (const pending of Array.from(pendingRequests.values())) { + if (pending.turn.providerTurnId === turn.providerTurnId) { + yield* resolveRuntimeRequest(pending.nativeRequestId, "cancelled"); + } + } + yield* emitProviderTurn(state, turn, status, completedAt); + const threadDisposition = terminal?.threadDisposition ?? "reusable"; + let providerThreadStatus: OrchestrationV2ProviderThread["status"] = "idle"; + if (turn.isRoot) { + providerThreadStatus = "active"; + } else if (threadDisposition === "broken") { + providerThreadStatus = "error"; + } + yield* updateProviderThread(state, { + status: providerThreadStatus, + nativeConversationHeadRef: + turn.nativeInputId === null + ? state.providerThread.nativeConversationHeadRef + : providerRef(turn.nativeInputId, "weak"), + }); + state.activeTurn = null; + if (!turn.isRoot) { + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE2_PROVIDER, + node: { + id: turn.rootNodeId, + threadId: turn.threadId, + runId: null, + parentNodeId: null, + rootNodeId: turn.rootNodeId, + kind: "root_turn", + status, + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef: providerRef(state.nativeSessionId), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: turn.startedAt, + completedAt, + }, + }); + const context = state.parentSubagent; + if (context !== null) { + const assistantResult = Array.from(turn.parts.values()).findLast( + (part): part is OpenCode2TextPart => + part.kind === "text" && part.text.trim().length > 0, + )?.text; + context.status = status; + context.progress = undefined; + if ( + (status === "completed" || status === "interrupted") && + assistantResult !== undefined + ) { + context.result = assistantResult; + } else if (status === "failed") { + const failure = + terminal?.failure ?? + turn.providerRetry?.failure ?? + makeProviderFailure({ + message: sessionEntity.lastError ?? undefined, + class: "provider_error", + }); + context.result = failure.message; + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE2_PROVIDER, + turnItem: makeProviderFailureTurnItem({ + idAllocator, + driver: OPENCODE2_PROVIDER, + threadId: turn.threadId, + runId: null, + nodeId: turn.rootNodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + itemOrdinal: itemOrdinal(turn, `terminal-failure:${turn.providerTurnId}`), + failure, + ...(turn.providerRetry === null + ? {} + : { + retry: turn.providerRetry.retry, + retryStartedAt: turn.providerRetry.startedAt, + }), + occurredAt: completedAt, + }), + }); + } + yield* emitSubagentContext(context); + } + return; + } + const anotherTurnIsActive = Array.from(threads.values()).some( + (candidate) => candidate.activeTurn?.isRoot === true, + ); + let providerSessionStatus: OrchestrationV2ProviderSession["status"] = "ready"; + if (anotherTurnIsActive) { + providerSessionStatus = "running"; + } else if (status === "failed") { + providerSessionStatus = "error"; + } + yield* updateProviderSession( + providerSessionStatus, + status === "failed" ? sessionEntity.lastError : null, + ); + if (status === "failed") { + yield* emitProviderEvent({ + type: "turn.terminal", + driver: OPENCODE2_PROVIDER, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + runOrdinal: turn.runOrdinal, + failureItemOrdinal: itemOrdinal(turn, `terminal-failure:${turn.providerTurnId}`), + status, + failure: + terminal?.failure ?? + turn.providerRetry?.failure ?? + makeProviderFailure({ + message: sessionEntity.lastError ?? undefined, + class: "provider_error", + }), + ...(turn.providerRetry === null + ? {} + : { + retry: turn.providerRetry.retry, + retryStartedAt: turn.providerRetry.startedAt, + }), + threadDisposition, + }); + return; + } + yield* emitProviderEvent({ + type: "turn.terminal", + driver: OPENCODE2_PROVIDER, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + runOrdinal: turn.runOrdinal, + status, + failure: null, + threadDisposition, + }); + }); + + /** Resolve the active turn for a session id, or nothing if it settled. */ + const activeFor = ( + sessionID: string | undefined, + ): { state: OpenCode2ThreadState; turn: ActiveOpenCode2Turn } | null => { + if (sessionID === undefined) return null; + const state = threads.get(sessionID); + const turn = state?.activeTurn; + if (state === undefined || turn === null || turn === undefined || turn.finalized) { + return null; + } + return { state, turn }; + }; + + const runtimeRequestProjectionFor = (active: { + readonly state: OpenCode2ThreadState; + readonly turn: ActiveOpenCode2Turn; + }) => { + const parent = active.state.parentSubagent; + return parent === null ? active : { state: parent.parentState, turn: parent.parentTurn }; + }; + + const createChildTurn = Effect.fnUntraced(function* ( + state: OpenCode2ThreadState, + inputID: string, + ) { + const context = state.parentSubagent; + if (state.appThread === null || context === null) return null; + const now = yield* DateTime.now; + const rootNodeId = idAllocator.derive.nodeFromProviderItem({ + driver: OPENCODE2_PROVIDER, + nativeItemId: `${state.nativeSessionId}:root:${inputID}`, + }); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: OPENCODE2_PROVIDER, + nativeTurnId: inputID, + }); + const providerTurn: OrchestrationV2ProviderTurn = { + id: providerTurnId, + providerThreadId: state.providerThread.id, + nodeId: rootNodeId, + runAttemptId: null, + nativeTurnRef: providerRef(inputID, "weak"), + ordinal: state.nextChildTurnOrdinal++, + status: "running", + startedAt: now, + completedAt: null, + }; + const itemOrdinals = openCode2ChildTurnItemOrdinals(providerTurn.ordinal); + const turn: ActiveOpenCode2Turn = { + isRoot: false, + providerBufferedContinuation: false, + threadId: state.appThread.id, + runId: null, + rootNodeId, + appThread: state.appThread, + modelSelection: state.appThread.modelSelection, + runtimePolicy: context.parentTurn.runtimePolicy, + providerTurnId, + runOrdinal: context.parentTurn.runOrdinal, + startedAt: now, + itemOrdinals: new Map(), + parts: new Map(), + toolIdsByCallId: new Map(), + providerTurn, + nextItemOrdinal: itemOrdinals.next, + nativeInputId: inputID, + activeCompaction: null, + executionStarted: false, + interrupted: false, + finalized: false, + providerRetry: null, + }; + state.activeTurn = turn; + state.providerTurns.set(String(providerTurnId), providerTurn); + const userMessageId = idAllocator.derive.messageFromProviderItem({ + driver: OPENCODE2_PROVIDER, + nativeItemId: inputID, + }); + const userTurnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: OPENCODE2_PROVIDER, + nativeItemId: inputID, + }); + const userArtifacts = makeSubagentConversationArtifacts({ + messageId: userMessageId, + turnItemId: userTurnItemId, + threadId: turn.threadId, + rootNodeId, + providerThreadId: state.providerThread.id, + providerTurnId, + nativeItemRef: providerRef(inputID, "weak"), + role: "user", + text: context.prompt, + ordinal: itemOrdinals.user, + now, + }); + state.messages.set(String(userMessageId), userArtifacts.message); + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE2_PROVIDER, + node: { + id: rootNodeId, + threadId: turn.threadId, + runId: null, + parentNodeId: null, + rootNodeId, + kind: "root_turn", + status: "running", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId, + nativeItemRef: providerRef(inputID, "weak"), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }); + yield* emitProviderTurn(state, turn, "running", null); + yield* updateProviderThread(state, { + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? providerTurn.ordinal, + lastRunOrdinal: providerTurn.ordinal, + nativeConversationHeadRef: providerRef(inputID, "weak"), + }); + yield* emitProviderEvent({ + type: "message.updated", + driver: OPENCODE2_PROVIDER, + message: userArtifacts.message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE2_PROVIDER, + turnItem: userArtifacts.turnItem, + }); + context.status = "running"; + context.progress = undefined; + yield* emitSubagentContext(context); + return turn; + }); + + const textPartId = (kind: "text" | "reasoning", messageId: string, ordinal: number) => + `${messageId}:${kind}:${ordinal}`; + + const upsertTextPart = Effect.fnUntraced(function* ( + state: OpenCode2ThreadState, + turn: ActiveOpenCode2Turn, + kind: "text" | "reasoning", + data: { readonly assistantMessageID: string; readonly ordinal: number }, + update: { readonly delta?: string; readonly text?: string; readonly completed?: boolean }, + ) { + const id = textPartId(kind, data.assistantMessageID, data.ordinal); + const startedAt = yield* DateTime.now; + const existing = turn.parts.get(id); + const part: OpenCode2TextPart = + existing !== undefined && existing.kind !== "tool" + ? existing + : { kind, id, startedAt, text: "", completed: false }; + if (update.text !== undefined) part.text = update.text; + else if (update.delta !== undefined) part.text += update.delta; + if (update.completed === true) part.completed = true; + turn.parts.set(id, part); + yield* emitTextPart(state, turn, part); + }); + + const upsertToolPart = Effect.fnUntraced(function* ( + state: OpenCode2ThreadState, + turn: ActiveOpenCode2Turn, + callId: string, + update: { + readonly name?: string; + readonly input?: Record; + readonly inputDelta?: string; + readonly output?: string; + readonly structured?: Record; + readonly status?: OpenCode2ToolStatus; + readonly errorMessage?: string; + }, + ) { + const now = yield* DateTime.now; + const id = turn.toolIdsByCallId.get(callId) ?? `tool:${callId}`; + turn.toolIdsByCallId.set(callId, id); + const existing = turn.parts.get(id); + const part: OpenCode2ToolPart = + existing !== undefined && existing.kind === "tool" + ? existing + : { + kind: "tool", + id, + callId, + startedAt: now, + // The name arrives on `session.tool.input.started`, ahead of + // every other event for this call, so this placeholder only + // shows if 2.x ever reorders them. + name: update.name ?? "tool", + input: {}, + inputText: "", + output: undefined, + structured: undefined, + status: "pending", + errorMessage: undefined, + completedAt: null, + }; + if (update.name !== undefined) part.name = update.name; + if (update.inputDelta !== undefined) part.inputText += update.inputDelta; + if (update.input !== undefined) part.input = update.input; + if (update.output !== undefined) part.output = update.output; + if (update.structured !== undefined) part.structured = update.structured; + if (update.errorMessage !== undefined) part.errorMessage = update.errorMessage; + const preserveRunningShell = + update.status !== undefined && + (update.status === "completed" || update.status === "error") && + runningShellForPart(turn, part) !== undefined; + if (update.status !== undefined && !preserveRunningShell) { + part.status = update.status; + if (update.status === "completed" || update.status === "error") part.completedAt = now; + } + turn.parts.set(id, part); + yield* emitToolPart(state, turn, part); + }); + + const shellToolStatus = (shell: ShellInfoV2): OpenCode2ToolStatus => { + if (shell.status === "running") return "running"; + if (shell.status === "exited" && shell.exit === 0) return "completed"; + return "error"; + }; + + const registerShellProjection = Effect.fnUntraced(function* ( + state: OpenCode2ThreadState, + shell: ShellInfoV2, + ) { + const existing = shellProjections.get(shell.id); + if (existing !== undefined) { + existing.status = shell.status; + existing.part.status = shellToolStatus(shell); + existing.part.structured = { + ...existing.part.structured, + ...(shell.exit === undefined ? {} : { exit: shell.exit }), + }; + if (existing.part.status !== "running") { + existing.part.completedAt = yield* DateTime.now; + } + yield* emitToolPart(existing.state, existing.turn, existing.part); + return existing; + } + + const turn = state.activeTurn; + if (turn === null) return null; + const associated = Array.from(turn.parts.values()).find( + (part): part is OpenCode2ToolPart => + part.kind === "tool" && + openCodeToolProjectionKind(part.name) === "command_execution" && + recordString(part.input, "command", "cmd") === shell.command && + Array.from(shellProjections.values()).every((projection) => projection.part !== part), + ); + const now = yield* DateTime.now; + const part: OpenCode2ToolPart = + associated ?? + ({ + kind: "tool", + id: `shell:${shell.id}`, + callId: shell.id, + startedAt: dateTimeFromEpoch(shell.time.started, now), + name: "bash", + input: { command: shell.command }, + inputText: "", + output: undefined, + structured: shell.exit === undefined ? undefined : { exit: shell.exit }, + status: shellToolStatus(shell), + errorMessage: undefined, + completedAt: + shell.status === "running" ? null : dateTimeFromEpoch(shell.time.completed, now), + } satisfies OpenCode2ToolPart); + part.status = shellToolStatus(shell); + if (shell.exit !== undefined) { + part.structured = { ...part.structured, exit: shell.exit }; + } + if (part.status !== "running") { + part.completedAt = dateTimeFromEpoch(shell.time.completed, now); + } + turn.parts.set(part.id, part); + const projection: OpenCode2ShellProjection = { + shellId: shell.id, + state, + turn, + part, + location: state.location, + status: shell.status, + }; + shellProjections.set(shell.id, projection); + shellSessionIds.set(shell.id, state.nativeSessionId); + yield* emitToolPart(state, turn, part); + return projection; + }); + + const readShellOutput = Effect.fnUntraced(function* ( + shellId: string, + location: SessionInfoV2["location"], + initial?: { + readonly output: string; + readonly cursor: number; + readonly truncated: boolean; + }, + ) { + let output = initial?.output ?? ""; + let cursor = initial?.cursor ?? 0; + let truncated = initial?.truncated ?? true; + while (truncated) { + const parameters = { + id: shellId, + location, + cursor: String(cursor), + limit: String(64 * 1024), + }; + const response = yield* sdkCall("shell.output", parameters, () => + client.v2.shell.output(parameters), + ); + const page = yield* unwrapOpenCode2Data<{ + readonly output: string; + readonly cursor: number; + readonly size: number; + readonly truncated: boolean; + }>("shell.output", response); + output += page.output; + if (!page.truncated) return output; + if (page.cursor <= cursor) { + return yield* protocolError( + `OpenCode 2 shell ${shellId} output cursor did not advance`, + ); + } + cursor = page.cursor; + truncated = page.truncated; + } + return output; + }); + + const completeShellProjection = Effect.fnUntraced(function* ( + shellId: string, + patch: { + readonly exit?: number; + readonly status: ShellInfoV2["status"]; + readonly output?: { + readonly output: string; + readonly cursor: number; + readonly truncated: boolean; + }; + }, + ) { + const projection = shellProjections.get(shellId); + if (projection === undefined) return; + projection.status = patch.status; + if ( + projection.turn.providerTurn.status !== "running" && + projection.turn.providerTurn.status !== "completed" + ) { + return; + } + const output = + patch.status === "killed" && patch.output === undefined + ? projection.part.output + : yield* readShellOutput(shellId, projection.location, patch.output).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to read OpenCode 2 shell output.", { + errorTag: causeErrorTag(cause), + provider: OPENCODE2_PROVIDER, + shellId, + }).pipe(Effect.as(projection.part.output)), + ), + ); + const completedAt = yield* DateTime.now; + projection.part.status = + patch.status === "exited" && patch.exit === 0 ? "completed" : "error"; + projection.part.completedAt = completedAt; + if (output !== undefined) projection.part.output = output; + projection.part.structured = { + ...projection.part.structured, + ...(patch.exit === undefined ? {} : { exit: patch.exit }), + }; + yield* emitToolPart(projection.state, projection.turn, projection.part); + }); + + const removeRunningShellsForTurn = Effect.fnUntraced(function* (turn: ActiveOpenCode2Turn) { + const running = Array.from(shellProjections.values()).filter( + (projection) => projection.turn === turn && projection.status === "running", + ); + for (const projection of running) { + const parameters = { + id: projection.shellId, + location: projection.location, + }; + yield* sdkCall("shell.remove", parameters, () => + client.v2.shell.remove(parameters), + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to stop an interrupted OpenCode 2 shell.", { + errorTag: causeErrorTag(cause), + provider: OPENCODE2_PROVIDER, + shellId: projection.shellId, + }), + ), + ); + } + }); + + const autoReplyPermission = Effect.fnUntraced(function* ( + sessionID: string, + requestID: string, + reply: "once" | "reject", + ) { + return yield* sdkCall("session.permission.reply", { sessionID, requestID, reply }, () => + client.v2.session.permission.reply({ sessionID, requestID, reply }), + ).pipe( + Effect.as(true), + Effect.catch((cause: OpenCode2RuntimeError) => + Effect.logWarning("Failed to answer an OpenCode 2 permission request.", { + category: cause.category, + operation: cause.operation, + provider: OPENCODE2_PROVIDER, + }).pipe(Effect.as(false)), + ), + ); + }); + + const failActiveTurns = Effect.fnUntraced(function* ( + detail: string, + failureClass: "transport_error" | "provider_error", + ) { + yield* updateProviderSession("error", detail); + for (const state of threads.values()) { + if (state.activeTurn !== null) { + yield* finalizeTurn(state, state.activeTurn, "failed", { + failure: makeProviderFailure({ message: detail, class: failureClass }), + threadDisposition: "broken", + }); + } + } + }); + + const offerPostSettleWake = Effect.fnUntraced(function* ( + state: OpenCode2ThreadState, + event: Extract, + suppressContinuation: boolean, + ) { + const wake: OpenCode2PostSettleWake = { + inputId: event.data.inputID, + events: [event], + disposition: suppressContinuation ? "suppress" : "replay", + promotedAfterExecutionStarted: false, + phase: "pending", + }; + state.postSettleWakes.push(wake); + if ( + suppressContinuation || + continuationRequests === undefined || + state.appThread === null + ) { + return; + } + yield* continuationRequests.offer({ + threadId: state.appThread.id, + providerThreadId: state.providerThread.id, + driver: OPENCODE2_PROVIDER, + detail: + event.data.input.type === "synthetic" + ? (event.data.input.data.description ?? null) + : null, + }); + }); + + const retireUnownedSuppressWakes = ( + state: OpenCode2ThreadState, + ownerInputIds: ReadonlySet, + ): void => { + for (let index = state.postSettleWakes.length - 1; index >= 0; index -= 1) { + const wake = state.postSettleWakes[index]; + if ( + wake?.disposition === "suppress" && + (wake.phase === "pending" || wake.phase === "executing") && + !ownerInputIds.has(wake.inputId) + ) { + state.postSettleWakes.splice(index, 1); + state.retiredSuppressWakes.delete(wake.inputId); + state.retiredSuppressWakes.set(wake.inputId, wake); + } + } + pruneOpenCode2RetiredSuppressWakes(state.retiredSuppressWakes); + }; + + const rememberPromotedInputId = (state: OpenCode2ThreadState, inputId: string): void => { + // Refresh an existing id so a genuinely later promotion keeps its + // evidence window instead of being evicted as an old duplicate. + state.promotedInputIds.delete(inputId); + state.promotedInputIds.add(inputId); + pruneOpenCode2PromotedInputIds(state.promotedInputIds); + }; + + const beginOpenCode2Execution = (state: OpenCode2ThreadState): void => { + const activeInputId = state.activeTurn?.nativeInputId; + const wakeInputIds = state.postSettleWakes + .filter((wake) => wake.phase === "pending" || wake.phase === "executing") + .map((wake) => wake.inputId); + const candidateInputIds = new Set( + activeInputId === null || activeInputId === undefined + ? wakeInputIds + : [activeInputId, ...wakeInputIds], + ); + const promotedOwners = state.sawInputPromotion + ? Array.from(state.promotedInputIds).filter((inputId) => candidateInputIds.has(inputId)) + : []; + + // OpenCode's promoted event is the authoritative input-to-execution + // boundary. Older clients omitted it, so the bounded fallback gives + // a known ordinary input priority. If there is no ordinary input and + // promotion has not identified an owner, every pending wake owns the + // execution for buffering purposes: swallowing unattributable output + // is safer than allowing it into the active visible turn. + const fallbackInputIds = + activeInputId !== null && activeInputId !== undefined ? [activeInputId] : wakeInputIds; + const ownership = + promotedOwners.length > 0 + ? { inputIds: new Set(promotedOwners), claimedByPromotion: true } + : { inputIds: new Set(fallbackInputIds), claimedByPromotion: false }; + state.activeExecution = ownership; + for (const inputId of ownership.inputIds) { + state.promotedInputIds.delete(inputId); + } + for (const wake of state.postSettleWakes) { + if (ownership.inputIds.has(wake.inputId) && wake.phase === "pending") { + wake.phase = "executing"; + } + } + if ( + (activeInputId !== null && activeInputId !== undefined) || + promotedOwners.length > 0 + ) { + retireUnownedSuppressWakes(state, ownership.inputIds); + } + }; + + const settlePostSettleWakes = ( + state: OpenCode2ThreadState, + event: V2Event, + ownerInputIds: ReadonlySet, + ): void => { + if (!openCode2EventEndsExecution(event)) return; + for (const wake of state.postSettleWakes) { + if ( + ownerInputIds.has(wake.inputId) && + (wake.phase === "pending" || wake.phase === "executing") + ) { + wake.phase = "ready"; + } + } + const ordinaryTurnOwnsExecution = + state.activeTurn?.nativeInputId !== null && + state.activeTurn?.nativeInputId !== undefined && + ownerInputIds.has(state.activeTurn.nativeInputId); + let replayOwnerKept = false; + for (let index = state.postSettleWakes.length - 1; index >= 0; index -= 1) { + const wake = state.postSettleWakes[index]; + if ( + wake !== undefined && + ownerInputIds.has(wake.inputId) && + wake.phase === "ready" && + (wake.disposition === "suppress" || + ordinaryTurnOwnsExecution || + (wake.disposition === "replay" && replayOwnerKept)) + ) { + state.postSettleWakes.splice(index, 1); + state.promotedInputIds.delete(wake.inputId); + } else if ( + wake !== undefined && + ownerInputIds.has(wake.inputId) && + wake.disposition === "replay" && + wake.phase === "ready" + ) { + replayOwnerKept = true; + } + } + }; + + const eventSessionId = (event: V2Event): string | undefined => { + const directSessionId = recordString(event.data, "sessionID"); + if (directSessionId !== undefined) return directSessionId; + + const formSessionId = recordString(recordValue(event.data, "form"), "sessionID"); + if (formSessionId !== undefined) return formSessionId; + + const info = recordValue(event.data, "info"); + const shellSessionId = recordString(recordValue(info, "metadata"), "sessionID"); + if (shellSessionId !== undefined) return shellSessionId; + + const nativeId = recordString(event.data, "requestID", "id"); + if (nativeId === undefined) return undefined; + return ( + shellSessionIds.get(nativeId) ?? + pendingRequestsByNativeId.get(nativeId)?.nativeSessionId + ); + }; + + const bufferPostSettleWakeEvent = (event: V2Event, isReplay: boolean): boolean => { + const sessionID = eventSessionId(event); + if (sessionID === undefined) return false; + const state = threads.get(sessionID); + if (state === undefined) return false; + // The replay loop feeds old provider events back through this same + // handler. They belong to the turn being replayed and must not + // settle a live execution that happens to be active beside it. + if (isReplay) return false; + if (event.type === "session.input.admitted" || event.type === "session.input.promoted") { + return false; + } + if (state.activeExecution === null && openCode2EventEndsExecution(event)) { + beginOpenCode2Execution(state); + } + const execution = state.activeExecution; + if (execution === null) return false; + const owningWakes = state.postSettleWakes.filter( + (candidate) => + execution.inputIds.has(candidate.inputId) && + (candidate.phase === "pending" || candidate.phase === "executing"), + ); + if (owningWakes.length === 0) return false; + for (const wake of owningWakes) wake.events.push(event); + const ordinaryTurnOwnsExecution = + state.activeTurn?.nativeInputId !== null && + state.activeTurn?.nativeInputId !== undefined && + execution.inputIds.has(state.activeTurn.nativeInputId); + const suppressesLateOutput = owningWakes.some( + (wake) => wake.disposition === "suppress" && wake.promotedAfterExecutionStarted, + ); + if (openCode2EventEndsExecution(event)) { + settlePostSettleWakes(state, event, execution.inputIds); + if (!ordinaryTurnOwnsExecution) state.activeExecution = null; + } + // A suppressed wake promoted after an ordinary root has claimed the + // session-level execution is indistinguishable from that root's + // output. Prefer the cancellation boundary for non-terminal output; + // the terminal still reaches the root lifecycle so it can settle. + return suppressesLateOutput + ? !openCode2EventEndsExecution(event) + : !ordinaryTurnOwnsExecution; + }; + + const activeTurnOwnsOpenCode2Execution = ( + state: OpenCode2ThreadState, + turn: ActiveOpenCode2Turn, + replayWakeInputId?: string, + ): boolean => { + if (replayWakeInputId !== undefined) { + return replayWakeInputId === turn.nativeInputId; + } + if ( + turn.isRoot && + state.postSettleWakes.length === 0 && + state.retiredSuppressWakes.size === 0 && + (state.activeExecution === null || state.activeExecution.inputIds.size === 0) + ) { + return true; + } + if ( + !turn.isRoot && + state.postSettleWakes.length === 0 && + (state.activeExecution === null || state.activeExecution.inputIds.size === 0) + ) { + return true; + } + const inputId = turn.nativeInputId; + return inputId !== null && state.activeExecution?.inputIds.has(inputId) === true; + }; + + const handleEvent = Effect.fnUntraced(function* ( + event: V2Event, + context: OpenCode2EventHandlingContext = {}, + ) { + const isReplay = context.replayWakeInputId !== undefined; + yield* logProtocolEvent({ + direction: "incoming", + messageKind: "notification", + method: event.type, + payload: event, + }); + const isCancelledPostSettleWake = openCode2IsCancelledPostSettleWake(event); + const admittedState = + event.type === "session.input.admitted" ? threads.get(event.data.sessionID) : undefined; + if ( + admittedState !== undefined && + !isReplay && + openCode2IsPostSettleWakeAdmission(event, { + isChildSession: admittedState.parentSubagent !== null, + }) + ) { + yield* offerPostSettleWake(admittedState, event, isCancelledPostSettleWake); + return; + } + const eventState = + admittedState ?? + (recordString(event.data, "sessionID") === undefined + ? undefined + : threads.get(recordString(event.data, "sessionID")!)); + if (eventState !== undefined && !isReplay && event.type === "session.execution.started") { + beginOpenCode2Execution(eventState); + } + if (bufferPostSettleWakeEvent(event, isReplay)) return; + if ( + isCancelledPostSettleWake && + !isReplay && + (admittedState === undefined || admittedState.parentSubagent === null) + ) { + return; + } + switch (event.type) { + case "session.created": { + const nativeSession = event.data.info; + nativeChildSessions.set(nativeSession.id, nativeSession); + if (nativeSession.parentID === undefined) return; + const parentState = threads.get(nativeSession.parentID); + if (parentState === undefined) return; + const candidates = Array.from(subagentsByNativeItemId.values()).filter( + (context) => + context.parentState === parentState && + context.childSessionId === null && + !subagentsByChildSessionId.has(nativeSession.id), + ); + const context = + candidates.find( + (candidate) => + candidate.title !== null && candidate.title === nativeSession.title, + ) ?? candidates[0]; + if (context === undefined) return; + yield* bindSubagentChild(context, nativeSession); + yield* emitSubagentContext(context); + return; + } + case "session.input.promoted": { + const state = threads.get(event.data.sessionID); + if (state !== undefined && !isReplay) { + const retiredWake = state.retiredSuppressWakes.get(event.data.inputID); + if (retiredWake !== undefined) { + state.retiredSuppressWakes.delete(event.data.inputID); + retiredWake.phase = "pending"; + state.postSettleWakes.push(retiredWake); + } + const joinWake = + retiredWake ?? + state.postSettleWakes.find( + (wake) => wake.inputId === event.data.inputID && wake.phase === "pending", + ); + rememberPromotedInputId(state, event.data.inputID); + state.sawInputPromotion = true; + // A pending wake promoted during any active execution joins + // that execution. The protocol has no execution id, so this + // is deliberately uniform for replay and suppression wakes; + // concurrent-child attribution remains an explicit in-vivo gate. + if (state.activeExecution !== null && joinWake?.phase === "pending") { + state.activeExecution.inputIds.add(event.data.inputID); + state.activeExecution.claimedByPromotion = true; + joinWake.promotedAfterExecutionStarted = true; + joinWake.phase = "executing"; + state.promotedInputIds.delete(event.data.inputID); + } else if (state.activeExecution?.inputIds.has(event.data.inputID) === true) { + state.activeExecution.claimedByPromotion = true; + state.promotedInputIds.delete(event.data.inputID); + } + } + if (state !== undefined && state.activeTurn === null) { + yield* updateProviderThread(state, {}); + } + return; + } + case "session.agent.selected": { + const state = threads.get(event.data.sessionID); + if (state === undefined) return; + // Event ids are monotonic, so a replayed or out-of-order + // delivery must not resurrect an older agent selection. + if ( + state.lastAgentSelectedEventId !== null && + event.id <= state.lastAgentSelectedEventId + ) { + return; + } + state.lastAgentSelectedEventId = event.id; + state.boundAgent = event.data.agent; + const reflectedInteractionMode = openCode2InteractionModeForAgent(event.data.agent); + // A genuinely external switch (a future plan_exit flow, or + // another client on the same session) reflects into the + // thread's Build/Plan mode so the next turn does not push the + // stale mode back. Only the two native agents map onto the + // toggle, and subagent child sessions keep their own agents. + if ( + interactionModeReflections === undefined || + state.parentSubagent !== null || + state.appThread === null || + reflectedInteractionMode === null + ) { + return; + } + // Matching echoes still supersede older queued reflections. The + // worker drains them in native event order and command ids make + // retries safe. + yield* interactionModeReflections.offer({ + threadId: state.appThread.id, + driver: OPENCODE2_PROVIDER, + interactionMode: reflectedInteractionMode, + dedupeKey: `${OPENCODE2_PROVIDER}:${event.id}`, + }); + return; + } + case "session.shell.started": { + const state = threads.get(event.data.sessionID); + if (state === undefined) return; + yield* registerShellProjection(state, event.data.shell); + yield* updateProviderThread(state, {}); + return; + } + case "session.shell.ended": { + yield* completeShellProjection(event.data.shell.id, { + status: event.data.shell.status, + ...(event.data.shell.exit === undefined ? {} : { exit: event.data.shell.exit }), + output: event.data.output, + }); + shellProjections.delete(event.data.shell.id); + shellSessionIds.delete(event.data.shell.id); + const state = threads.get(event.data.sessionID); + if (state !== undefined) yield* updateProviderThread(state, {}); + return; + } + case "shell.created": { + const sessionID = recordString(event.data.info.metadata, "sessionID"); + if (sessionID === undefined) return; + shellSessionIds.set(event.data.info.id, sessionID); + const state = threads.get(sessionID); + if (state !== undefined) { + yield* registerShellProjection(state, event.data.info); + yield* updateProviderThread(state, {}); + } + return; + } + case "shell.exited": { + yield* completeShellProjection(event.data.id, { + status: event.data.status, + ...(event.data.exit === undefined ? {} : { exit: event.data.exit }), + }); + const sessionID = shellSessionIds.get(event.data.id); + shellProjections.delete(event.data.id); + shellSessionIds.delete(event.data.id); + if (sessionID === undefined) return; + const state = threads.get(sessionID); + if (state !== undefined) yield* updateProviderThread(state, {}); + return; + } + case "shell.deleted": { + const sessionID = shellSessionIds.get(event.data.id); + if (sessionID === undefined) return; + const projection = shellProjections.get(event.data.id); + if (projection !== undefined && projection.turn.finalized) { + yield* completeShellProjection(event.data.id, { status: "killed" }); + } + shellProjections.delete(event.data.id); + shellSessionIds.delete(event.data.id); + const state = threads.get(sessionID); + if (state !== undefined) yield* updateProviderThread(state, {}); + return; + } + case "session.input.admitted": { + const state = threads.get(event.data.sessionID); + if ( + state !== undefined && + state.activeTurn === null && + state.parentSubagent !== null + ) { + yield* createChildTurn(state, event.data.inputID); + } + const active = activeFor(event.data.sessionID); + if (active === null) return; + if (active.turn.nativeInputId === null) { + active.turn.nativeInputId = event.data.inputID; + yield* emitProviderTurn(active.state, active.turn, "running", null); + } + const rootInputId = active.turn.nativeInputId; + const activeExecution = active.state.activeExecution; + if ( + !isReplay && + rootInputId !== null && + activeExecution !== null && + !activeExecution.claimedByPromotion && + !activeExecution.inputIds.has(rootInputId) && + activeExecution.inputIds.size === 0 && + (active.turn.isRoot || active.state.postSettleWakes.length === 0) + ) { + const previousOwnerInputIds = new Set(activeExecution.inputIds); + // `session.execution.started` has only a session id. A + // promoted owner wins this boundary. Otherwise the ordinary + // admission claims the execution here, including when the + // fallback temporarily held pending wake ids. A later + // retired promotion joins the same boundary and remains + // suppressed. OpenCode cannot tell these orderings apart + // without an execution id, so this is the smallest + // deterministic policy. + activeExecution.inputIds.clear(); + activeExecution.inputIds.add(rootInputId); + active.turn.executionStarted = true; + // Fallback ownership is only a safety hold until a known + // ordinary input arrives. Move cancelled wakes out of the + // execution so their later promotion still joins the same + // boundary and remains suppressed. An unpromoted replay wake + // cannot be distinguished from this execution either, so its + // buffered events are discarded rather than re-parented into + // the ordinary root turn. + retireUnownedSuppressWakes(active.state, activeExecution.inputIds); + for (let index = active.state.postSettleWakes.length - 1; index >= 0; index -= 1) { + const wake = active.state.postSettleWakes[index]; + if ( + wake !== undefined && + wake.disposition === "replay" && + previousOwnerInputIds.has(wake.inputId) + ) { + active.state.postSettleWakes.splice(index, 1); + active.state.promotedInputIds.delete(wake.inputId); + } + } + } + return; + } + case "session.text.started": + case "session.text.delta": + case "session.text.ended": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + yield* upsertTextPart(active.state, active.turn, "text", event.data, { + ...("delta" in event.data ? { delta: event.data.delta } : {}), + ...("text" in event.data ? { text: event.data.text } : {}), + ...(event.type === "session.text.ended" ? { completed: true } : {}), + }); + return; + } + case "session.reasoning.started": + case "session.reasoning.delta": + case "session.reasoning.ended": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + yield* upsertTextPart(active.state, active.turn, "reasoning", event.data, { + ...("delta" in event.data ? { delta: event.data.delta } : {}), + ...("text" in event.data ? { text: event.data.text } : {}), + ...(event.type === "session.reasoning.ended" ? { completed: true } : {}), + }); + return; + } + case "session.compaction.started": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + const now = yield* DateTime.now; + const nativeItemId = event.data.inputID ?? event.id; + const current = active.turn.activeCompaction; + if (current !== null && current.id !== nativeItemId && current.status === "running") { + current.status = "cancelled"; + current.completedAt = now; + yield* emitCompaction(active.state, active.turn, current); + } + const compaction: OpenCode2Compaction = + current?.id === nativeItemId + ? current + : { + id: nativeItemId, + startedAt: dateTimeFromEpoch(event.created, now), + summary: "", + status: "running", + completedAt: null, + }; + compaction.status = "running"; + compaction.completedAt = null; + active.turn.activeCompaction = compaction; + yield* emitCompaction(active.state, active.turn, compaction); + return; + } + case "session.compaction.delta": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + const now = yield* DateTime.now; + const compaction = + active.turn.activeCompaction ?? + ({ + id: event.id, + startedAt: dateTimeFromEpoch(event.created, now), + summary: "", + status: "running", + completedAt: null, + } satisfies OpenCode2Compaction); + compaction.summary += event.data.text; + active.turn.activeCompaction = compaction; + yield* emitCompaction(active.state, active.turn, compaction); + return; + } + case "session.compaction.ended": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + const now = yield* DateTime.now; + const compaction = + active.turn.activeCompaction ?? + ({ + id: event.id, + startedAt: dateTimeFromEpoch(event.created, now), + summary: "", + status: "running", + completedAt: null, + } satisfies OpenCode2Compaction); + compaction.summary = event.data.text; + compaction.status = "completed"; + compaction.completedAt = dateTimeFromEpoch(event.created, now); + active.turn.activeCompaction = compaction; + yield* emitCompaction(active.state, active.turn, compaction); + return; + } + case "session.compaction.failed": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + const now = yield* DateTime.now; + const compaction = + active.turn.activeCompaction ?? + ({ + id: event.data.inputID ?? event.id, + startedAt: dateTimeFromEpoch(event.created, now), + summary: "", + status: "running", + completedAt: null, + } satisfies OpenCode2Compaction); + if (compaction.summary.length === 0) { + compaction.summary = event.data.error.message; + } + compaction.status = "failed"; + compaction.completedAt = dateTimeFromEpoch(event.created, now); + active.turn.activeCompaction = compaction; + yield* emitCompaction(active.state, active.turn, compaction); + return; + } + case "session.tool.input.started": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + yield* upsertToolPart(active.state, active.turn, event.data.callID, { + name: event.data.name, + status: "pending", + }); + return; + } + case "session.tool.input.delta": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + yield* upsertToolPart(active.state, active.turn, event.data.callID, { + inputDelta: event.data.delta, + }); + return; + } + case "session.tool.called": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + yield* upsertToolPart(active.state, active.turn, event.data.callID, { + input: event.data.input, + status: "running", + }); + return; + } + case "session.tool.progress": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + const output = toolContentText(event.data.content); + yield* upsertToolPart(active.state, active.turn, event.data.callID, { + ...(output === undefined ? {} : { output }), + structured: event.data.structured, + status: "running", + }); + return; + } + case "session.tool.success": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + const output = toolContentText(event.data.content); + yield* upsertToolPart(active.state, active.turn, event.data.callID, { + ...(output === undefined ? {} : { output }), + structured: event.data.structured, + status: "completed", + }); + return; + } + case "session.tool.failed": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + yield* upsertToolPart(active.state, active.turn, event.data.callID, { + output: event.data.error.message, + errorMessage: event.data.error.message, + status: "error", + }); + return; + } + case "session.retry.scheduled": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + const now = yield* DateTime.now; + const retry: OrchestrationV2ProviderRetry = { + attempt: Math.max(1, Math.floor(event.data.attempt)), + maxAttempts: null, + retryDelayMs: Math.max(0, Math.floor(event.data.at - DateTime.toEpochMillis(now))), + }; + const failure = makeProviderFailure({ + message: event.data.error.message, + code: event.data.error.type, + class: "provider_error", + retryable: true, + }); + active.turn.providerRetry = { + retry, + failure, + startedAt: + active.turn.providerRetry?.startedAt ?? dateTimeFromEpoch(event.created, now), + }; + const context = active.state.parentSubagent; + if (context !== null) { + context.status = "running"; + context.progress = + event.data.error.type === "provider.rate-limit" || + event.data.error.message.includes("429") + ? `Rate limited, retrying (attempt ${retry.attempt})` + : `Provider retry attempt ${retry.attempt}`; + yield* emitSubagentContext(context); + } + return; + } + case "permission.v2.asked": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + const permission = normalizeOpenCode2PermissionEvent("v2", event.data); + const autoReply = openCode2PermissionAutoReplyForSession( + active.turn.runtimePolicy, + sessionPermissions, + event.data.sessionID, + permission, + ); + if (autoReply !== null) { + const replied = yield* autoReplyPermission( + event.data.sessionID, + event.data.id, + autoReply, + ); + if (replied) return; + } + const projection = runtimeRequestProjectionFor(active); + yield* emitRuntimeRequest( + projection.state, + projection.turn, + event.data.sessionID, + event.data.id, + { + type: "permission", + ...permission, + }, + ); + return; + } + case "permission.v2.replied": + yield* resolveRuntimeRequest(event.data.requestID, "resolved"); + return; + case "question.v2.asked": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + const projection = runtimeRequestProjectionFor(active); + yield* emitRuntimeRequest( + projection.state, + projection.turn, + event.data.sessionID, + event.data.id, + { + type: "question", + questions: event.data.questions, + }, + ); + return; + } + case "question.v2.replied": + yield* resolveRuntimeRequest(event.data.requestID, "resolved"); + return; + case "question.v2.rejected": + yield* resolveRuntimeRequest(event.data.requestID, "cancelled"); + return; + // Current 2.x builds route the question tool through the form API; + // question.v2.asked no longer fires for it. + case "form.created": { + const active = activeFor(event.data.form.sessionID); + if (active === null) return; + const { questions, fieldKeys, optionValuesByLabel } = openCode2FormQuestions( + event.data.form, + ); + if (questions.length === 0) return; + const projection = runtimeRequestProjectionFor(active); + yield* emitRuntimeRequest( + projection.state, + projection.turn, + event.data.form.sessionID, + event.data.form.id, + { + type: "question", + questions, + formFieldKeys: fieldKeys, + formOptionValues: optionValuesByLabel, + }, + ); + return; + } + case "form.replied": + yield* resolveRuntimeRequest(event.data.id, "resolved"); + return; + case "form.cancelled": + yield* resolveRuntimeRequest(event.data.id, "cancelled"); + return; + case "permission.asked": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + const permission = normalizeOpenCode2PermissionEvent("legacy", event.data); + const autoReply = openCode2PermissionAutoReplyForSession( + active.turn.runtimePolicy, + sessionPermissions, + event.data.sessionID, + permission, + ); + if (autoReply !== null) { + const replied = yield* autoReplyPermission( + event.data.sessionID, + event.data.id, + autoReply, + ); + if (replied) return; + } + const projection = runtimeRequestProjectionFor(active); + yield* emitRuntimeRequest( + projection.state, + projection.turn, + event.data.sessionID, + event.data.id, + { + type: "permission", + ...permission, + }, + ); + return; + } + case "permission.replied": + yield* resolveRuntimeRequest(event.data.requestID, "resolved"); + return; + case "question.asked": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + const projection = runtimeRequestProjectionFor(active); + yield* emitRuntimeRequest( + projection.state, + projection.turn, + event.data.sessionID, + event.data.id, + { + type: "question", + questions: event.data.questions, + }, + ); + return; + } + case "question.replied": + yield* resolveRuntimeRequest(event.data.requestID, "resolved"); + return; + case "question.rejected": + yield* resolveRuntimeRequest(event.data.requestID, "cancelled"); + return; + case "session.execution.started": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + // Execution terminals carry only a session id. This start event + // is the correlation barrier that keeps a late prior terminal + // from settling a turn whose input did not own this execution. + if ( + !activeTurnOwnsOpenCode2Execution( + active.state, + active.turn, + context.replayWakeInputId, + ) + ) { + return; + } + active.turn.executionStarted = true; + return; + } + case "session.execution.succeeded": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + if ( + !activeTurnOwnsOpenCode2Execution( + active.state, + active.turn, + context.replayWakeInputId, + ) + ) { + return; + } + if ( + !openCode2ShouldSettleTurn( + "execution-terminal", + active.turn.executionStarted, + active.turn.interrupted, + ) + ) { + return; + } + yield* finalizeTurn( + active.state, + active.turn, + active.turn.interrupted ? "interrupted" : "completed", + ); + if (!isReplay) active.state.activeExecution = null; + return; + } + case "session.execution.interrupted": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + if ( + !activeTurnOwnsOpenCode2Execution( + active.state, + active.turn, + context.replayWakeInputId, + ) + ) { + return; + } + if ( + !openCode2ShouldSettleTurn( + "execution-interrupted", + active.turn.executionStarted, + active.turn.interrupted, + ) + ) { + return; + } + active.turn.interrupted = true; + yield* finalizeTurn(active.state, active.turn, "interrupted", { + threadDisposition: openCode2InterruptedThreadDisposition(event.data.reason), + }); + if (!isReplay) active.state.activeExecution = null; + return; + } + case "session.execution.failed": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + if ( + !activeTurnOwnsOpenCode2Execution( + active.state, + active.turn, + context.replayWakeInputId, + ) + ) { + return; + } + if (!openCode2ShouldSettleTurn("execution-terminal", active.turn.executionStarted)) { + return; + } + const message = event.data.error.message; + if (active.turn.isRoot) yield* updateProviderSession("error", message); + yield* finalizeTurn(active.state, active.turn, "failed", { + failure: makeProviderFailure({ + message, + code: event.data.error.type, + class: "provider_error", + retryable: active.turn.providerRetry === null ? null : true, + }), + }); + if (!isReplay) active.state.activeExecution = null; + return; + } + // 2.x settles on `session.execution.*`; `session.idle` is only a + // backstop for builds that never enter the authoritative lifecycle. + case "session.idle": { + const active = activeFor(event.data.sessionID); + if (active === null) return; + if ( + !activeTurnOwnsOpenCode2Execution( + active.state, + active.turn, + context.replayWakeInputId, + ) + ) { + return; + } + if (!openCode2ShouldSettleTurn("idle", active.turn.executionStarted)) return; + yield* finalizeTurn( + active.state, + active.turn, + active.turn.interrupted ? "interrupted" : "completed", + ); + if (!isReplay) active.state.activeExecution = null; + return; + } + case "session.error": { + const activeSessionIDs = Array.from(threads.values()) + .filter((state) => state.activeTurn !== null && !state.activeTurn.finalized) + .map((state) => state.nativeSessionId); + const targetSessionIDs = openCode2SessionErrorTargetSessionIds( + event.data.sessionID, + activeSessionIDs, + ); + const message = openCode2SessionErrorMessage(event.data); + const isAbort = event.data.error?.name === "MessageAbortedError"; + const targetsRoot = + event.data.sessionID === undefined || + targetSessionIDs.some( + (sessionID) => threads.get(sessionID)?.parentSubagent === null, + ); + if (!isAbort && targetsRoot) yield* updateProviderSession("error", message); + for (const sessionID of targetSessionIDs) { + const active = activeFor(sessionID); + if (active === null) continue; + yield* finalizeTurn( + active.state, + active.turn, + openCode2SessionErrorStatus(event.data, active.turn.interrupted), + { + failure: makeProviderFailure({ + message, + code: event.data.error?.name ?? null, + class: "provider_error", + }), + threadDisposition: event.data.sessionID === undefined ? "broken" : "reusable", + }, + ); + } + // Finalizing one of several active turns temporarily marks the + // shared provider session as running. Restore the unscoped + // provider failure after every affected turn has closed. + if (!isAbort && targetsRoot && targetSessionIDs.length > 1) { + yield* updateProviderSession("error", message); + } + return; + } + default: + return; + } + }); + + const subscription = yield* sdkCall("event.subscribe", {}, () => + client.v2.event.subscribe({ signal: abortController.signal }), + ); + yield* Scope.addFinalizer( + scope, + Effect.sync(() => abortController.abort()), + ); + yield* Stream.fromAsyncIterable( + subscription.stream, + (cause) => + new OpenCode2RuntimeError({ + operation: "event.subscribe", + category: "event-subscription-failed", + cause, + }), + ).pipe( + Stream.runForEach(handleEvent), + Effect.exit, + Effect.flatMap((exit) => + abortController.signal.aborted || Exit.isSuccess(exit) + ? Effect.void + : Effect.gen(function* () { + yield* Effect.logError("OpenCode 2 event subscription failed.", { + errorTag: causeErrorTag(exit.cause), + provider: OPENCODE2_PROVIDER, + }); + yield* failActiveTurns( + openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), + "transport_error", + ); + }), + ), + Effect.forkIn(scope), + ); + + if (!connection.external && connection.exitCode !== null) { + yield* connection.exitCode.pipe( + Effect.flatMap((code) => + abortController.signal.aborted + ? Effect.void + : failActiveTurns( + `OpenCode 2 server exited unexpectedly (${code}).`, + "transport_error", + ), + ), + Effect.forkIn(scope), + ); + } + + const registerThread = ( + nativeSession: SessionInfoV2, + providerThread: OrchestrationV2ProviderThread, + ): OpenCode2ThreadState => { + const existing = threads.get(nativeSession.id); + if (existing !== undefined) { + existing.location = nativeSession.location; + existing.providerThread = providerThread; + if (nativeSession.model !== undefined) { + existing.boundModel = `${nativeSession.model.providerID}/${nativeSession.model.id}`; + existing.boundVariant = + normalizeOpenCode2Variant(nativeSession.model.variant) ?? null; + } + existing.boundAgent = nativeSession.agent ?? existing.boundAgent; + return existing; + } + const state: OpenCode2ThreadState = { + nativeSessionId: nativeSession.id, + location: nativeSession.location, + providerThread, + appThread: null, + activeTurn: null, + boundModel: + nativeSession.model === undefined + ? null + : `${nativeSession.model.providerID}/${nativeSession.model.id}`, + boundVariant: normalizeOpenCode2Variant(nativeSession.model?.variant) ?? null, + boundAgent: nativeSession.agent ?? null, + lastAgentSelectedEventId: null, + providerTurns: new Map(), + messages: new Map(), + runtimeRequests: new Map(), + postSettleWakes: [], + retiredSuppressWakes: new Map(), + promotedInputIds: new Set(), + sawInputPromotion: false, + activeExecution: null, + parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null, + nextChildTurnOrdinal: 1, + }; + threads.set(nativeSession.id, state); + return state; + }; + + /** + * Catalog for `clampOpenCode2Variant`. Cached per provider session: it + * only changes when the spawned server restarts. A fresh 2.x server + * reports an empty catalog until bootstrap finishes, so an empty + * result is used for the current call but never cached, and a failed + * fetch is not cached either, so later turns retry. Only successful + * non-empty fetches are stored, which also keeps a losing concurrent + * fetch from clobbering a good cache. + */ + let variantCatalog: ReadonlyMap> | null = null; + const readVariantCatalog = sdkCall("model.list", {}, () => + client.v2.model.list({ location: { directory: cwd } }), + ).pipe( + Effect.flatMap((response) => + unwrapOpenCode2Data>("model.list", response).pipe( + Effect.map((models) => { + const catalog = new Map>(); + for (const model of models) { + catalog.set( + `${model.providerID}/${model.id}`, + new Set(model.variants.map((entry) => entry.id)), + ); + } + return catalog as ReadonlyMap>; + }), + ), + ), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("Failed to load the OpenCode 2 variant catalog.", { + errorTag: causeErrorTag(cause), + provider: OPENCODE2_PROVIDER, + }).pipe(Effect.as(null)), + ), + ); + const knownVariantsForModel = Effect.fnUntraced(function* (modelSlug: string) { + if (variantCatalog !== null) return variantCatalog.get(modelSlug) ?? null; + const fetched = yield* retryEmptyOpenCode2VariantCatalog(readVariantCatalog); + if (fetched !== null && fetched.size > 0 && variantCatalog === null) { + variantCatalog = fetched; + } + return fetched?.get(modelSlug) ?? null; + }); + + let agentCatalog: ReadonlySet | null = null; + const knownAgentIDs = Effect.fnUntraced(function* () { + if (agentCatalog !== null) return agentCatalog; + const fetched = yield* sdkCall("agent.list", {}, () => + client.v2.agent.list({ location: { directory: cwd } }), + ).pipe( + Effect.flatMap((response) => + unwrapOpenCode2Data>("agent.list", response).pipe( + Effect.map( + (agents) => new Set(agents.map((agent) => agent.id)) as ReadonlySet, + ), + ), + ), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("Failed to load the OpenCode 2 agent catalog.", { + errorTag: causeErrorTag(cause), + provider: OPENCODE2_PROVIDER, + }).pipe(Effect.as(null)), + ), + ); + if (fetched?.has("build") && fetched.has("plan")) { + agentCatalog = fetched; + } + return fetched; + }); + + const warnDroppedVariant = (modelSlug: string, droppedVariant: string | null) => + droppedVariant === null + ? Effect.void + : Effect.logWarning("Dropping a variant the OpenCode 2 catalog cannot validate.", { + provider: OPENCODE2_PROVIDER, + model: modelSlug, + variant: droppedVariant, + }); + + /** + * 2.x binds the model, variant, and agent to the session, not to the + * prompt, so a selection change between turns has to be pushed before + * the prompt. A variant-less switch resets the session to the + * server-resolved default variant. + */ + const alignSessionSelection = Effect.fnUntraced(function* ( + state: OpenCode2ThreadState, + modelSelection: ModelSelection, + interactionMode?: ProviderInteractionMode, + ) { + const sessionID = state.nativeSessionId; + // Subagent child sessions run their own native agents (general, + // explore, customs); the Build/Plan mapping only owns top-level + // sessions. + const selection = openCode2SessionSelectionParameters( + modelSelection, + state.parentSubagent === null ? interactionMode : undefined, + state.parentSubagent === null ? yield* knownAgentIDs() : null, + ); + const plan = planOpenCode2VariantAlignment({ + boundModel: state.boundModel, + boundVariant: state.boundVariant, + model: modelSelection.model, + rawVariant: getModelSelectionStringOptionValue(modelSelection, "variant"), + knownVariants: + selection.model.variant === undefined + ? null + : yield* knownVariantsForModel(modelSelection.model), + }); + yield* warnDroppedVariant(modelSelection.model, plan.droppedVariant); + if (plan.switchNeeded) { + const model = { + id: selection.model.id, + providerID: selection.model.providerID, + ...(plan.variant === undefined ? {} : { variant: plan.variant }), + }; + yield* sdkCall("session.switchModel", { sessionID, model }, () => + client.v2.session.switchModel({ sessionID, model }), + ); + state.boundModel = modelSelection.model; + state.boundVariant = plan.variant ?? null; + } + const agent = selection.agent; + if (agent !== undefined && state.boundAgent !== agent) { + yield* sdkCall("session.switchAgent", { sessionID, agent }, () => + client.v2.session.switchAgent({ sessionID, agent }), + ); + state.boundAgent = agent; + } + }); + + const promptPayload = (message: ProviderAdapterV2TurnInput["message"]) => { + const text = message.text.trim(); + const files = toOpenCode2FileAttachments({ + attachments: message.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + }); + if (text.length === 0 && files.length === 0) { + throw protocolError("OpenCode 2 turns require text or at least one valid attachment"); + } + return { + ...(text.length === 0 ? {} : { text }), + ...(files.length === 0 ? {} : { files }), + }; + }; + + const readSnapshot = Effect.fnUntraced(function* ( + providerThread: OrchestrationV2ProviderThread, + ) { + const sessionID = nativeThreadId(providerThread); + const response = yield* sdkCall("message.list", { sessionID }, () => + client.v2.message.list({ sessionID }), + ); + const nativeMessages = yield* unwrapOpenCode2Data>( + "message.list", + response, + ); + const state = threads.get(sessionID); + const snapshotNow = yield* DateTime.now; + const messages: Array = nativeMessages.flatMap( + (info) => { + let text = ""; + if (info.type === "user") { + text = info.text; + } else if (info.type === "assistant") { + text = info.content + .filter((entry) => entry.type === "text") + .map((entry) => entry.text) + .join("\n"); + } + if (text.trim().length === 0) return []; + const createdAt = dateTimeFromEpoch(info.time.created, snapshotNow); + return [ + { + createdBy: info.type === "user" ? ("user" as const) : ("agent" as const), + creationSource: "provider" as const, + id: idAllocator.derive.messageFromProviderItem({ + driver: OPENCODE2_PROVIDER, + nativeItemId: info.id, + }), + threadId: providerThread.appThreadId ?? input.threadId, + runId: null, + nodeId: null, + role: info.type === "user" ? ("user" as const) : ("assistant" as const), + text, + attachments: [], + streaming: false, + createdAt, + updatedAt: createdAt, + }, + ]; + }, + ); + const lastUser = nativeMessages.findLast((info) => info.type === "user")?.id; + return { + providerThread: { + ...providerThread, + providerSessionId: input.providerSessionId, + nativeConversationHeadRef: + lastUser === undefined ? null : providerRef(lastUser, "weak"), + status: "idle" as const, + updatedAt: snapshotNow, + }, + providerTurns: state === undefined ? [] : [...state.providerTurns.values()], + messages, + runtimeRequests: state === undefined ? [] : [...state.runtimeRequests.values()], + providerPayload: nativeMessages, + }; + }); + + const inspectPendingBackgroundWork = Effect.fnUntraced(function* ( + state: OpenCode2ThreadState, + ) { + const sessionID = state.nativeSessionId; + return yield* openCode2PendingWorkForSession({ + sessionID, + pending: sdkCall("session.pending.list", { sessionID }, () => + client.v2.session.pending.list({ sessionID }), + ).pipe( + Effect.flatMap((response) => + unwrapOpenCode2Data>("session.pending.list", response), + ), + ), + shells: sdkCall("shell.list", { location: state.location }, () => + client.v2.shell.list({ location: state.location }), + ).pipe( + Effect.flatMap((response) => + unwrapOpenCode2Data>("shell.list", response), + ), + Effect.tap((shells) => + Effect.sync(() => { + for (const shell of shells) { + if (shell.metadata.sessionID === sessionID) { + shellSessionIds.set(shell.id, sessionID); + } + } + }), + ), + ), + }); + }); + + const hasPendingBackgroundWorkForState = (state: OpenCode2ThreadState) => + inspectPendingBackgroundWork(state).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to inspect OpenCode 2 pending background work.", { + errorTag: causeErrorTag(cause), + provider: OPENCODE2_PROVIDER, + providerThreadId: state.providerThread.id, + }).pipe(Effect.as(false)), + ), + ); + + const waitForT3Mcp = Effect.fnUntraced(function* () { + if (!hasT3Mcp) return; + let lastStatus = "missing"; + for (let attempt = 0; attempt < 50; attempt++) { + const response = yield* sdkCall("mcp.list", {}, () => client.v2.mcp.list()); + const servers = yield* unwrapOpenCode2Data>( + "mcp.list", + response, + ); + const server = servers.find((candidate) => candidate.name === OPENCODE2_T3_MCP_NAME); + lastStatus = server?.status.status ?? "missing"; + if (lastStatus === "connected") return; + if (lastStatus !== "missing" && lastStatus !== "pending") { + return yield* new OpenCode2RuntimeError({ + operation: "mcp.list", + category: "mcp-connect-failed", + cause: server?.status, + }); + } + if (attempt < 49) yield* Effect.sleep("100 millis"); + } + return yield* new OpenCode2RuntimeError({ + operation: "mcp.list", + category: "mcp-connect-timeout", + }); + }); + + const installT3OrchestrationInstructions = Effect.fnUntraced(function* (sessionID: string) { + if (!hasT3Mcp) return; + yield* sdkCall( + "session.instructions.entry.put", + { sessionID, key: OPENCODE2_T3_INSTRUCTION_KEY }, + () => + client.v2.session.instructions.entry.put({ + sessionID, + key: OPENCODE2_T3_INSTRUCTION_KEY, + value: T3_CODE_ORCHESTRATION_INSTRUCTIONS, + }), + ); + }); + + yield* waitForT3Mcp(); + + const runtimeSession: ProviderAdapterV2SessionRuntime = { + instanceId: options.instanceId, + driver: OPENCODE2_PROVIDER, + providerSessionId: input.providerSessionId, + providerSession: sessionEntity, + events: Stream.fromEffectRepeat(Queue.take(events)), + hasPendingBackgroundWork: Effect.gen(function* () { + for (const state of threads.values()) { + if (yield* hasPendingBackgroundWorkForState(state)) return true; + } + return false; + }), + hasPendingBackgroundWorkForThread: (providerThread) => + Effect.gen(function* () { + const sessionID = nativeThreadId(providerThread); + const state = threads.get(sessionID); + if (state === undefined) { + return yield* protocolError( + `OpenCode 2 session ${sessionID} is not registered for pending-work inspection`, + ); + } + return yield* inspectPendingBackgroundWork(state); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to inspect OpenCode 2 pending background work.", { + errorTag: causeErrorTag(cause), + provider: OPENCODE2_PROVIDER, + providerThreadId: providerThread.id, + }).pipe(Effect.as(false)), + ), + ), + ensureThread: (threadInput) => + Effect.gen(function* () { + if (threadInput.existingProviderThread !== undefined) { + return yield* runtimeSession.resumeThread({ + providerThread: threadInput.existingProviderThread, + }); + } + const selection = openCode2SessionSelectionParameters( + threadInput.modelSelection, + threadInput.runtimePolicy.interactionMode, + yield* knownAgentIDs(), + ); + const agent = selection.agent; + const clamp = clampOpenCode2Variant( + selection.model.variant, + selection.model.variant === undefined + ? null + : yield* knownVariantsForModel(threadInput.modelSelection.model), + ); + yield* warnDroppedVariant(threadInput.modelSelection.model, clamp.droppedVariant); + const parameters = { + ...selection, + model: { + id: selection.model.id, + providerID: selection.model.providerID, + ...(clamp.variant === undefined ? {} : { variant: clamp.variant }), + }, + location: { directory: threadInput.runtimePolicy.cwd ?? cwd }, + }; + const response = yield* sdkCall("session.create", parameters, () => + client.v2.session.create(parameters), + ); + const nativeSession = yield* unwrapOpenCode2Data( + "session.create", + response, + ); + yield* installT3OrchestrationInstructions(nativeSession.id); + const createdAt = yield* DateTime.now; + const providerThread = makeProviderThread({ + idAllocator, + providerInstanceId: options.instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadInput.threadId, + nativeSession, + now: createdAt, + }); + const state = registerThread(nativeSession, providerThread); + state.boundModel = threadInput.modelSelection.model; + state.boundVariant = clamp.variant ?? null; + if (agent !== undefined) state.boundAgent = agent; + return providerThread; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterEnsureThreadError({ + driver: OPENCODE2_PROVIDER, + threadId: threadInput.threadId, + cause, + }), + ), + ), + resumeThread: (threadInput) => + Effect.gen(function* () { + const sessionID = nativeThreadId(threadInput.providerThread); + const response = yield* sdkCall("session.get", { sessionID }, () => + client.v2.session.get({ sessionID }), + ); + const nativeSession = yield* unwrapOpenCode2Data( + "session.get", + response, + ); + yield* installT3OrchestrationInstructions(sessionID); + const resumedAt = yield* DateTime.now; + const providerThread = { + ...threadInput.providerThread, + providerSessionId: input.providerSessionId, + status: "idle" as const, + updatedAt: dateTimeFromEpoch(nativeSession.time.updated, resumedAt), + }; + registerThread(nativeSession, providerThread); + return providerThread; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterResumeThreadError({ + driver: OPENCODE2_PROVIDER, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), + ), + deleteThread: (providerThread) => + Effect.gen(function* () { + const sessionID = nativeThreadId(providerThread); + yield* removeOpenCode2Session( + sessionID, + sdkCall("session.remove", { sessionID }, () => + client.v2.session.remove({ sessionID }, { throwOnError: false }), + ), + ); + threads.delete(sessionID); + sessionPermissions.delete(sessionID); + }).pipe( + Effect.mapError((cause) => + protocolError(`Failed to delete OpenCode 2 session ${providerThread.id}`, cause), + ), + ), + startTurn: (turnInput) => + Effect.gen(function* () { + const sessionID = nativeThreadId(turnInput.providerThread); + const state = threads.get(sessionID); + if (state === undefined) { + return yield* protocolError(`OpenCode 2 session ${sessionID} is not registered`); + } + if (state.activeTurn !== null) { + return yield* protocolError( + `OpenCode 2 provider thread ${turnInput.providerThread.id} already has an active turn`, + ); + } + if ( + spawnedWithInjectedAllowPolicy && + !warnedAboutInjectedAllowPolicy && + !isOpenCodeAllowAllPolicy(turnInput.runtimePolicy) + ) { + warnedAboutInjectedAllowPolicy = true; + yield* Effect.logWarning( + "OpenCode 2 session was spawned with an allow-all permission policy; a stricter runtime mode will not re-gate suppressed permission asks until the session is reopened.", + { + provider: OPENCODE2_PROVIDER, + providerSessionId: input.providerSessionId, + threadId: turnInput.threadId, + runtimeMode: turnInput.runtimePolicy.runtimeMode, + }, + ); + } + const providerBufferedContinuation = + turnInput.message.createdBy === "agent" && + turnInput.message.creationSource === "provider"; + const wake = (() => { + if (!providerBufferedContinuation) return undefined; + const readyWakeIndex = state.postSettleWakes.findIndex( + (candidate) => candidate.disposition === "replay" && candidate.phase === "ready", + ); + const wakeIndex = + readyWakeIndex >= 0 + ? readyWakeIndex + : state.postSettleWakes.findIndex( + (candidate) => candidate.disposition === "replay", + ); + if (wakeIndex < 0) return undefined; + const replayWake = state.postSettleWakes.splice(wakeIndex, 1)[0]; + if (replayWake !== undefined) state.promotedInputIds.delete(replayWake.inputId); + return replayWake; + })(); + const startedAt = yield* DateTime.now; + const syntheticNativeTurnId = `${sessionID}:attempt:${turnInput.attemptId}`; + const providerTurnId = idAllocator.derive.providerTurn({ + driver: OPENCODE2_PROVIDER, + nativeTurnId: syntheticNativeTurnId, + }); + const providerTurn: OrchestrationV2ProviderTurn = { + id: providerTurnId, + providerThreadId: turnInput.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"), + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }; + const turn: ActiveOpenCode2Turn = { + isRoot: true, + providerBufferedContinuation, + threadId: turnInput.threadId, + runId: turnInput.runId, + rootNodeId: turnInput.rootNodeId, + appThread: turnInput.appThread, + modelSelection: turnInput.modelSelection, + runtimePolicy: turnInput.runtimePolicy, + providerTurnId, + runOrdinal: turnInput.runOrdinal, + startedAt, + itemOrdinals: new Map(), + parts: new Map(), + toolIdsByCallId: new Map(), + providerTurn, + nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, + nativeInputId: wake?.inputId ?? null, + activeCompaction: null, + executionStarted: false, + interrupted: false, + finalized: false, + providerRetry: null, + }; + state.appThread = turnInput.appThread; + state.activeTurn = turn; + state.providerTurns.set(String(providerTurnId), providerTurn); + yield* emitProviderTurn(state, turn, "running", null); + yield* updateProviderThread(state, { + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, + lastRunOrdinal: turnInput.runOrdinal, + }); + yield* updateProviderSession("running", null); + if (providerBufferedContinuation) { + // OpenCode already ran this input. The app turn only gives its + // buffered native events durable run ownership. + if (wake === undefined) { + yield* finalizeTurn(state, turn, "completed"); + return; + } + for (const event of wake.events) { + yield* handleEvent(event, { replayWakeInputId: wake.inputId }); + } + return; + } + const payload = promptPayload(turnInput.message); + yield* alignSessionSelection( + state, + turnInput.modelSelection, + turnInput.runtimePolicy.interactionMode, + ); + const prompted = yield* sdkCall("session.prompt", { sessionID, ...payload }, () => + client.v2.session.prompt({ sessionID, ...payload }), + ).pipe( + Effect.tapError((cause) => + finalizeTurn(state, turn, "failed", { + failure: makeProviderFailure({ cause, class: "provider_error" }), + }), + ), + ); + // The admitted input id is the closest native turn correlation + // point 2.x offers, and it arrives on the prompt response before + // `session.input.admitted` reaches the event stream. + const admittedId = recordString(prompted.data?.data, "id"); + if (admittedId !== undefined && turn.nativeInputId === null) { + turn.nativeInputId = admittedId; + yield* emitProviderTurn(state, turn, "running", null); + } + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterTurnStartError({ + driver: OPENCODE2_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + steerTurn: (steerInput) => + Effect.gen(function* () { + const sessionID = nativeThreadId(steerInput.providerThread); + const state = threads.get(sessionID); + const turn = state?.activeTurn; + if ( + state === undefined || + turn === undefined || + turn === null || + turn.providerTurnId !== steerInput.providerTurnId + ) { + return yield* protocolError( + `OpenCode 2 turn ${steerInput.providerTurnId} is not active`, + ); + } + const payload = promptPayload(steerInput.message); + yield* sdkCall("session.prompt", { sessionID, ...payload, delivery: "steer" }, () => + client.v2.session.prompt({ sessionID, ...payload, delivery: "steer" }), + ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterSteerRunError({ + driver: OPENCODE2_PROVIDER, + providerThreadId: steerInput.providerThread.id, + providerTurnId: steerInput.providerTurnId, + cause, + }), + ), + ), + interruptTurn: (interruptInput) => + Effect.gen(function* () { + const sessionID = nativeThreadId(interruptInput.providerThread); + const state = threads.get(sessionID); + const turn = state?.activeTurn; + if ( + turn === undefined || + turn === null || + turn.providerTurnId !== interruptInput.providerTurnId + ) { + return yield* protocolError( + `OpenCode 2 turn ${interruptInput.providerTurnId} is not active`, + ); + } + turn.interrupted = true; + yield* sdkCall("session.interrupt", { sessionID }, () => + client.v2.session.interrupt({ sessionID }), + ).pipe(Effect.tapError(() => Effect.sync(() => (turn.interrupted = false)))); + yield* removeRunningShellsForTurn(turn); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterInterruptError({ + driver: OPENCODE2_PROVIDER, + providerThreadId: interruptInput.providerThread.id, + providerTurnId: interruptInput.providerTurnId, + cause, + }), + ), + ), + respondToRuntimeRequest: (requestInput) => + Effect.gen(function* () { + const pending = pendingRequests.get(String(requestInput.requestId)); + if (pending === undefined) { + return yield* protocolError( + `No pending OpenCode 2 request ${requestInput.requestId}`, + ); + } + const sessionID = pending.nativeSessionId; + const requestID = pending.nativeRequestId; + if (pending.questions !== undefined) { + if (requestInput.answers === undefined) { + return yield* protocolError( + `OpenCode 2 question request ${requestInput.requestId} requires answers`, + ); + } + const answers = pending.questions.map((question, index) => { + const raw = + requestInput.answers?.[openCode2QuestionId(index, question.header)] ?? + requestInput.answers?.[question.header] ?? + requestInput.answers?.[question.question]; + if (Array.isArray(raw)) { + return raw.filter((value): value is string => typeof value === "string"); + } + if (typeof raw === "string") return raw.trim().length > 0 ? [raw] : []; + return []; + }); + if (pending.formFieldKeys !== undefined) { + const answer = openCode2FormAnswer( + pending.formFieldKeys, + answers, + pending.formOptionValues, + pending.questions.map((question) => question.multiple === true), + ); + yield* sdkCall( + "session.form.reply", + { sessionID, formID: requestID, answer }, + () => + client.v2.session.form.reply({ + sessionID, + formID: requestID, + formReply: { answer }, + }), + ); + return; + } + yield* sdkCall("session.question.reply", { sessionID, requestID, answers }, () => + client.v2.session.question.reply({ + sessionID, + requestID, + questionV2Reply: { answers }, + }), + ); + return; + } + if (requestInput.decision === undefined) { + return yield* protocolError( + `OpenCode 2 approval request ${requestInput.requestId} requires a decision`, + ); + } + if (requestInput.decision === "acceptForSession") { + rememberOpenCode2SessionPermission( + sessionPermissions, + sessionID, + pending.permission, + ); + } + const reply = + requestInput.decision === "accept" || requestInput.decision === "acceptForSession" + ? ("once" as const) + : ("reject" as const); + yield* sdkCall("session.permission.reply", { sessionID, requestID, reply }, () => + client.v2.session.permission.reply({ sessionID, requestID, reply }), + ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRuntimeRequestResponseError({ + driver: OPENCODE2_PROVIDER, + requestId: requestInput.requestId, + cause, + }), + ), + ), + readThreadSnapshot: (snapshotInput) => + readSnapshot(snapshotInput.providerThread).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterReadThreadSnapshotError({ + driver: OPENCODE2_PROVIDER, + providerThreadId: snapshotInput.providerThread.id, + cause, + }), + ), + ), + rollbackThread: (rollbackInput) => + Effect.gen(function* () { + const sessionID = nativeThreadId(rollbackInput.providerThread); + const state = threads.get(sessionID); + if (state?.activeTurn !== null && state?.activeTurn !== undefined) { + return yield* protocolError( + `Cannot roll back OpenCode 2 thread ${rollbackInput.providerThread.id} while a turn is active`, + ); + } + const response = yield* sdkCall("message.list", { sessionID }, () => + client.v2.message.list({ sessionID }), + ); + const nativeMessages = yield* unwrapOpenCode2Data>( + "message.list", + response, + ); + let boundaryMessageId: string | undefined; + if (rollbackInput.target.type === "thread_start") { + boundaryMessageId = nativeMessages.find((info) => info.type === "user")?.id; + } else { + boundaryMessageId = openCodeBoundaryAfterProviderTurn( + rollbackInput.providerThreadTurns, + rollbackInput.target.providerTurn.id, + ); + } + if (boundaryMessageId !== undefined) { + // Stage then commit: 2.x split 1.x's single `session.revert` + // into a reversible boundary plus an explicit commit. + yield* sdkCall( + "session.revert.stage", + { sessionID, messageID: boundaryMessageId, files: true }, + () => + client.v2.session.revert.stage({ + sessionID, + messageID: boundaryMessageId!, + files: true, + }), + ); + yield* sdkCall("session.revert.commit", { sessionID }, () => + client.v2.session.revert.commit({ sessionID }), + ); + } + const snapshot = yield* readSnapshot(rollbackInput.providerThread); + return { + ...snapshot, + providerThread: { + ...snapshot.providerThread, + nativeConversationHeadRef: + rollbackInput.target.type === "provider_turn" + ? rollbackInput.target.providerTurn.nativeTurnRef + : null, + }, + }; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRollbackThreadError({ + driver: OPENCODE2_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause, + }), + ), + ), + forkThread: (forkInput) => + Effect.gen(function* () { + const sessionID = nativeThreadId(forkInput.sourceProviderThread); + const sourceState = threads.get(sessionID); + if (sourceState?.activeTurn !== null && sourceState?.activeTurn !== undefined) { + return yield* protocolError( + `Cannot fork OpenCode 2 thread ${forkInput.sourceProviderThread.id} while a turn is active`, + ); + } + let boundaryMessageId: string | undefined; + if (forkInput.providerTurnId !== undefined) { + const sourceTurns = forkInput.sourceProviderTurns ?? []; + const selected = sourceTurns.find((turn) => turn.id === forkInput.providerTurnId); + if (selected === undefined) { + return yield* protocolError( + `OpenCode 2 fork boundary turn ${forkInput.providerTurnId} was not found`, + ); + } + boundaryMessageId = openCodeBoundaryAfterProviderTurn(sourceTurns, selected.id); + } + const parameters = openCode2ForkParameters(sessionID, boundaryMessageId); + const response = yield* sdkCall("session.fork", parameters, () => + client.v2.session.fork(parameters), + ); + const nativeSession = yield* unwrapOpenCode2Data( + "session.fork", + response, + ); + yield* installT3OrchestrationInstructions(nativeSession.id); + const forkedAt = yield* DateTime.now; + const providerThread = makeProviderThread({ + idAllocator, + providerInstanceId: options.instanceId, + providerSessionId: input.providerSessionId, + appThreadId: forkInput.targetThreadId, + ...(forkInput.ownerNodeId === undefined + ? {} + : { ownerNodeId: forkInput.ownerNodeId }), + nativeSession, + forkedFrom: { + providerThreadId: forkInput.sourceProviderThread.id, + ...(forkInput.providerTurnId === undefined + ? {} + : { providerTurnId: forkInput.providerTurnId }), + }, + now: forkedAt, + }); + registerThread(nativeSession, providerThread); + return providerThread; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterForkThreadError({ + driver: OPENCODE2_PROVIDER, + providerThreadId: forkInput.sourceProviderThread.id, + cause, + }), + ), + ), + }; + + return runtimeSession; + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterOpenSessionError({ + driver: OPENCODE2_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ), + ), + }); +} + +export type OpenCode2AdapterV2DriverEnv = + | OpenCode2Runtime + | IdAllocatorV2 + | ProviderEventLoggers + | ServerConfig; + +export const OpenCode2AdapterV2Driver: ProviderAdapterDriver< + OpenCode2Settings, + OpenCode2AdapterV2DriverEnv +> = { + driverKind: OPENCODE2_DRIVER_KIND, + configSchema: OpenCode2SettingsSchema, + defaultConfig: (): OpenCode2Settings => DEFAULT_OPENCODE2_SETTINGS, + create: Effect.fn("OpenCode2AdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcessEnvironment; + const openCode2Runtime = yield* OpenCode2Runtime; + const idAllocator = yield* IdAllocatorV2; + const continuationRequests = yield* ProviderContinuationRequests; + const interactionModeReflections = yield* ProviderInteractionModeReflections; + const providerEventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; + return makeOpenCode2AdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: applyOpenCode2ProviderEnvironment( + input.config, + mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + ), + runtime: openCode2Runtime, + idAllocator, + serverConfig, + continuationRequests, + interactionModeReflections, + ...(providerEventLoggers.native === undefined + ? {} + : { nativeEventLogger: providerEventLoggers.native }), + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: OPENCODE2_DRIVER_KIND, + instanceId: input.instanceId, + detail: "Failed to create OpenCode 2 v2 adapter.", + cause, + }), + ), + ), + ), +}; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index e0834bec858..064cc588da7 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -659,6 +659,26 @@ function toolStatus(part: ToolPart): { } } +/** + * Node/item statuses for a tool that never reported its own terminal state. + * Mirrors the turn's outcome so an interrupted turn does not leave a spinner. + */ +export function terminalToolStatus(status: TerminalTurnStatus): { + readonly node: OrchestrationV2ExecutionNode["status"]; + readonly item: OrchestrationV2TurnItem["status"]; +} { + switch (status) { + case "completed": + return { node: "completed", item: "completed" }; + case "interrupted": + return { node: "interrupted", item: "interrupted" }; + case "cancelled": + return { node: "cancelled", item: "cancelled" }; + case "failed": + return { node: "failed", item: "failed" }; + } +} + function toolInput(part: ToolPart): Record { return part.state.input; } @@ -1538,6 +1558,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid label: option.label.trim() || "Option", description: option.description.trim() || option.label.trim() || "Option", })), + multiSelect: false, })); const runtimeRequestTurnItem = ( diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts index 52c3bb0c1cc..2c5b57aa185 100644 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts @@ -23,6 +23,8 @@ import { NoOpProviderEventLoggers, ProviderEventLoggers, } from "../provider/Layers/ProviderEventLoggers.ts"; +import * as OpenCode2Runtime from "../provider/opencode2Runtime.ts"; +import * as SpawnedProcessReaper from "../provider/SpawnedProcessReaper.ts"; import { OpenCodeRuntimeLive } from "../provider/opencodeRuntime.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -62,6 +64,10 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe NodeServices.layer, FetchHttpClient.layer, OpenCodeRuntimeLive.pipe(Layer.provide(NodeServices.layer)), + OpenCode2Runtime.layer.pipe( + Layer.provide(SpawnedProcessReaper.layer), + Layer.provide(NodeServices.layer), + ), Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), ), ), diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 748cfdb2904..dc5fd92e488 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -3,10 +3,13 @@ import { CheckpointScopeId, CommandId, MessageId, + OrchestrationV2ProviderSessionJson, + OrchestrationV2ProviderThreadJson, ProviderSessionId, RunAttemptId, ProviderApprovalDecision, ProviderUserInputAnswers, + ProviderInstanceId, ProviderThreadId, ProviderTurnId, RunId, @@ -30,6 +33,12 @@ 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), + /** Set only on thread deletion: remove the provider-native thread before detaching. */ + deleteProviderThread: Schema.optional(Schema.Boolean), + /** Persisted deletion targets for retries after the managed runtime has stopped. */ + providerInstanceId: Schema.optional(ProviderInstanceId), + providerSession: Schema.optional(OrchestrationV2ProviderSessionJson), + providerThreads: Schema.optional(Schema.Array(OrchestrationV2ProviderThreadJson)), }), 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 b6d4c7c4ae2..93c2c31fab4 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -103,6 +103,18 @@ export const executorLayer: Layer.Layer< ...(effect.request.revokeMcpCredential === undefined ? {} : { revokeMcpCredential: effect.request.revokeMcpCredential }), + ...(effect.request.deleteProviderThread === undefined + ? {} + : { deleteProviderThread: effect.request.deleteProviderThread }), + ...(effect.request.providerInstanceId === undefined + ? {} + : { providerInstanceId: effect.request.providerInstanceId }), + ...(effect.request.providerSession === undefined + ? {} + : { providerSession: effect.request.providerSession }), + ...(effect.request.providerThreads === undefined + ? {} + : { providerThreads: effect.request.providerThreads }), }) .pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts index 5e5bc62ba0d..351c77253cf 100644 --- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts @@ -23,6 +23,8 @@ import { NoOpProviderEventLoggers, ProviderEventLoggers, } from "../provider/Layers/ProviderEventLoggers.ts"; +import * as OpenCode2Runtime from "../provider/opencode2Runtime.ts"; +import * as SpawnedProcessReaper from "../provider/SpawnedProcessReaper.ts"; import { OpenCodeRuntimeLive } from "../provider/opencodeRuntime.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -61,6 +63,10 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe NodeServices.layer, FetchHttpClient.layer, OpenCodeRuntimeLive.pipe(Layer.provide(NodeServices.layer)), + OpenCode2Runtime.layer.pipe( + Layer.provide(SpawnedProcessReaper.layer), + Layer.provide(NodeServices.layer), + ), Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), ), ), diff --git a/apps/server/src/orchestration-v2/Orchestrator.settled.test.ts b/apps/server/src/orchestration-v2/Orchestrator.settled.test.ts new file mode 100644 index 00000000000..e55b4277450 --- /dev/null +++ b/apps/server/src/orchestration-v2/Orchestrator.settled.test.ts @@ -0,0 +1,969 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + type ModelSelection, + NodeId, + type OrchestrationV2AppThread, + type OrchestrationV2DomainEvent, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2Run, + type OrchestrationV2Subagent, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + 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 Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; +import { ServerConfig } from "../config.ts"; +import { layer as mcpSessionRegistryTestLayer } from "../mcp/McpSessionRegistry.testkit.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import { EventSinkV2 } from "./EventSink.ts"; +import { OrchestratorV2 } from "./Orchestrator.ts"; +import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import { threadShellFromProjection } from "./ProjectionStore.ts"; +import { OrchestrationV2EventSinkLayerLive, OrchestrationV2LayerLive } from "./runtimeLayer.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-orchestration-v2-settled-", +}); + +const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", +} satisfies ModelSelection; + +const driver = ProviderDriverKind.make("codex"); +const orchestrationAdapter = { + instanceId: modelSelection.instanceId, + driver, + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: () => Effect.die("sessions are not used by settle tests"), +} as ProviderAdapterV2Shape; +const providerInstance = { + instanceId: modelSelection.instanceId, + driverKind: driver, + continuationIdentity: { + driverKind: driver, + continuationKey: "codex:test", + }, + displayName: "Codex test", + enabled: true, + snapshot: {} as ProviderInstance["snapshot"], + orchestrationAdapter, + textGeneration: {} as ProviderInstance["textGeneration"], +} satisfies ProviderInstance; + +const TestProviderInstanceRegistry = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Effect.succeed(instanceId === providerInstance.instanceId ? providerInstance : undefined), + listInstances: Effect.succeed([providerInstance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, +}); + +const VcsDriverRegistryTestLayer = VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProcess.layer), + Layer.provide(ServerConfigLayer), + Layer.provide(NodeServices.layer), +); + +const CheckpointStoreTestLayer = CheckpointStore.layer.pipe( + Layer.provide(VcsDriverRegistryTestLayer), +); + +const TestLayer = Layer.merge(OrchestrationV2LayerLive, OrchestrationV2EventSinkLayerLive).pipe( + Layer.provide(mcpSessionRegistryTestLayer), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provide(CheckpointStoreTestLayer), + Layer.provide(ServerConfigLayer), + Layer.provide(ServerSettingsService.layerTest()), + Layer.provide(TestProviderInstanceRegistry), + Layer.provide(NodeServices.layer), +); + +function createThreadCommand(input: { + readonly commandId: string; + readonly threadId: ThreadId; + readonly projectId: ProjectId; +}) { + return { + type: "thread.create" as const, + createdBy: "user" as const, + creationSource: "web" as const, + commandId: CommandId.make(input.commandId), + threadId: input.threadId, + projectId: input.projectId, + title: "Settled lifecycle thread", + modelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + }; +} + +function makeRun(input: { + readonly runId: RunId; + readonly threadId: ThreadId; + readonly status: OrchestrationV2Run["status"]; + readonly now: DateTime.Utc; + readonly providerThreadId?: ProviderThreadId | null; +}): OrchestrationV2Run { + return { + id: input.runId, + threadId: input.threadId, + ordinal: 1, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: input.providerThreadId ?? null, + userMessageId: MessageId.make(`message:${input.runId}`), + rootNodeId: NodeId.make(`node:${input.runId}`), + activeAttemptId: null, + status: input.status, + requestedAt: input.now, + startedAt: input.status === "queued" || input.status === "preparing" ? null : input.now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + ...(input.status === "queued" ? { queuePosition: 1 } : {}), + }; +} + +function makeProviderThread(input: { + readonly id: ProviderThreadId; + readonly threadId: ThreadId; + readonly now: DateTime.Utc; +}): OrchestrationV2ProviderThread { + return { + id: input.id, + driver, + providerInstanceId: modelSelection.instanceId, + providerSessionId: null, + appThreadId: input.threadId, + ownerNodeId: null, + nativeThreadRef: { + driver, + nativeId: String(input.id), + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: input.now, + updatedAt: input.now, + }; +} + +function makeProviderChildThread(input: { + readonly parent: OrchestrationV2AppThread; + readonly childThreadId: ThreadId; + readonly providerThreadId: ProviderThreadId; + readonly now: DateTime.Utc; +}): OrchestrationV2AppThread { + return { + ...input.parent, + id: input.childThreadId, + title: `Provider child ${input.childThreadId}`, + createdBy: "agent", + creationSource: "provider", + activeProviderThreadId: input.providerThreadId, + lineage: { + parentThreadId: input.parent.id, + relationshipToParent: "subagent", + rootThreadId: input.parent.lineage.rootThreadId, + }, + createdAt: input.now, + updatedAt: input.now, + }; +} + +function makeProviderChildTurn(input: { + readonly providerThreadId: ProviderThreadId; + readonly providerTurnId: ProviderTurnId; + readonly nodeId: NodeId; + readonly now: DateTime.Utc; + readonly status: OrchestrationV2ProviderTurn["status"]; +}): OrchestrationV2ProviderTurn { + return { + id: input.providerTurnId, + providerThreadId: input.providerThreadId, + nodeId: input.nodeId, + runAttemptId: null, + nativeTurnRef: { + driver, + nativeId: String(input.providerTurnId), + strength: "weak", + }, + ordinal: 1, + status: input.status, + startedAt: input.now, + completedAt: input.status === "running" ? null : input.now, + }; +} + +function makeProviderNativeSubagent(input: { + readonly parentThreadId: ThreadId; + readonly parentNodeId: NodeId; + readonly runId?: RunId | null; + readonly providerThreadId: ProviderThreadId; + readonly childThreadId: ThreadId; + readonly now: DateTime.Utc; + readonly status: OrchestrationV2Subagent["status"]; +}): OrchestrationV2Subagent { + return { + id: input.parentNodeId, + threadId: input.parentThreadId, + runId: input.runId ?? null, + parentNodeId: input.parentNodeId, + origin: "provider_native", + createdBy: "agent", + driver, + providerInstanceId: modelSelection.instanceId, + providerThreadId: input.providerThreadId, + childThreadId: input.childThreadId, + nativeTaskRef: { + driver, + nativeId: String(input.parentNodeId), + strength: "strong", + }, + prompt: "Run the background task.", + title: null, + model: modelSelection.model, + status: input.status, + result: null, + startedAt: input.now, + completedAt: input.status === "running" ? null : input.now, + updatedAt: input.now, + }; +} + +it.layer(TestLayer)("OrchestratorV2 provider-native Stop invariants", (it) => { + it.effect("interrupts only running direct provider-native children and de-duplicates races", () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const eventSink = yield* EventSinkV2; + const sql = yield* SqlClient.SqlClient; + const now = DateTime.makeUnsafe("2026-07-31T12:00:00.000Z"); + const parentThreadId = ThreadId.make("thread:provider-native-stop-parent"); + const projectId = ProjectId.make("project:provider-native-stop"); + + yield* orchestrator.dispatch( + createThreadCommand({ + commandId: "cmd:provider-native-stop:create", + threadId: parentThreadId, + projectId, + }), + ); + const parent = (yield* orchestrator.getThreadProjection(parentThreadId)).thread; + + const makeChild = ( + label: string, + status: OrchestrationV2ProviderTurn["status"], + subagentStatus: OrchestrationV2Subagent["status"], + childParent: OrchestrationV2AppThread = parent, + runId: RunId | null = null, + ) => { + const childThreadId = ThreadId.make(`thread:provider-native-stop:${label}`); + const providerThreadId = ProviderThreadId.make( + `provider-thread:provider-native-stop:${label}`, + ); + const providerTurnId = ProviderTurnId.make(`provider-turn:provider-native-stop:${label}`); + const nodeId = NodeId.make(`node:provider-native-stop:${label}`); + const childThread = makeProviderChildThread({ + parent: childParent, + childThreadId, + providerThreadId, + now, + }); + const providerThread = { + ...makeProviderThread({ + id: providerThreadId, + threadId: childThreadId, + now, + }), + providerSessionId: ProviderSessionId.make( + `provider-session:provider-native-stop:${label}`, + ), + ownerNodeId: + childParent.id === parent.id + ? nodeId + : NodeId.make(`node:provider-native-stop:${childParent.id}:owner`), + status: status === "running" ? ("active" as const) : ("idle" as const), + appThreadId: childThreadId, + }; + const providerTurn = makeProviderChildTurn({ + providerThreadId, + providerTurnId, + nodeId, + now, + status, + }); + const subagent = makeProviderNativeSubagent({ + parentThreadId: childParent.id, + parentNodeId: nodeId, + providerThreadId, + childThreadId, + now, + status: subagentStatus, + runId, + }); + return { childThread, providerThread, providerTurn, subagent }; + }; + + const rolledBackSpawnRunId = RunId.make("run:provider-native-stop:rolled-back-spawn"); + const first = makeChild("first", "running", "running", parent, rolledBackSpawnRunId); + const second = makeChild("second", "running", "running"); + const terminal = makeChild("terminal", "completed", "completed"); + const nested = makeChild("nested", "running", "running", first.childThread); + const missingProviderRow = makeChild("stale-missing-provider-row", "running", "running"); + const nullProviderSession = makeChild("stale-null-provider-session", "running", "running"); + const nullProviderSessionThread = { + ...nullProviderSession.providerThread, + providerSessionId: null, + }; + const staleNullProviderThread: OrchestrationV2Subagent = { + ...first.subagent, + id: NodeId.make("node:provider-native-stop:stale-null-provider-thread"), + parentNodeId: NodeId.make("node:provider-native-stop:stale-null-provider-thread"), + providerThreadId: null, + childThreadId: terminal.childThread.id, + }; + const staleMissingChild: OrchestrationV2Subagent = { + ...first.subagent, + id: NodeId.make("node:provider-native-stop:stale-missing-child"), + parentNodeId: NodeId.make("node:provider-native-stop:stale-missing-child"), + childThreadId: ThreadId.make("thread:provider-native-stop:stale-missing-child"), + }; + const staleNoRunningTurn: OrchestrationV2Subagent = { + ...terminal.subagent, + id: NodeId.make("node:provider-native-stop:stale-no-running-turn"), + parentNodeId: NodeId.make("node:provider-native-stop:stale-no-running-turn"), + status: "running", + completedAt: null, + updatedAt: now, + }; + const nestedOnTerminal: OrchestrationV2Subagent = { + ...nested.subagent, + id: NodeId.make("node:provider-native-stop:terminal-nested"), + threadId: terminal.childThread.id, + parentNodeId: terminal.providerTurn.nodeId, + }; + const seededEvents: Array = []; + seededEvents.push({ + id: EventId.make("event:provider-native-stop:rolled-back-spawn"), + type: "run.created", + threadId: parentThreadId, + runId: rolledBackSpawnRunId, + nodeId: NodeId.make("node:provider-native-stop:rolled-back-spawn"), + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: makeRun({ + runId: rolledBackSpawnRunId, + threadId: parentThreadId, + status: "rolled_back", + now, + }), + }); + for (const child of [ + first, + second, + terminal, + nested, + nullProviderSession, + missingProviderRow, + ]) { + seededEvents.push({ + id: EventId.make(`event:provider-native-stop:${child.childThread.id}:created`), + type: "thread.created", + threadId: child.childThread.id, + occurredAt: now, + payload: child.childThread, + }); + } + for (const child of [first, second, terminal, nested]) { + seededEvents.push({ + id: EventId.make(`event:provider-native-stop:${child.providerThread.id}:updated`), + type: "provider-thread.updated", + threadId: child.childThread.id, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: child.providerThread, + }); + seededEvents.push({ + id: EventId.make(`event:provider-native-stop:${child.providerTurn.id}:updated`), + type: "provider-turn.updated", + threadId: child.childThread.id, + nodeId: child.providerTurn.nodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: child.providerTurn, + }); + } + seededEvents.push({ + id: EventId.make(`event:provider-native-stop:${nullProviderSessionThread.id}:updated`), + type: "provider-thread.updated", + threadId: nullProviderSession.childThread.id, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: nullProviderSessionThread, + }); + seededEvents.push({ + id: EventId.make( + `event:provider-native-stop:${nullProviderSession.providerTurn.id}:updated`, + ), + type: "provider-turn.updated", + threadId: nullProviderSession.childThread.id, + nodeId: nullProviderSession.providerTurn.nodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: nullProviderSession.providerTurn, + }); + seededEvents.push({ + id: EventId.make( + `event:provider-native-stop:${missingProviderRow.providerTurn.id}:updated`, + ), + type: "provider-turn.updated", + threadId: missingProviderRow.childThread.id, + nodeId: missingProviderRow.providerTurn.nodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: missingProviderRow.providerTurn, + }); + for (const subagent of [ + first.subagent, + second.subagent, + terminal.subagent, + staleNullProviderThread, + staleMissingChild, + staleNoRunningTurn, + missingProviderRow.subagent, + nullProviderSession.subagent, + ]) { + seededEvents.push({ + id: EventId.make(`event:provider-native-stop:${subagent.id}:subagent`), + type: "subagent.updated", + threadId: parentThreadId, + nodeId: subagent.id, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: subagent, + }); + } + seededEvents.push({ + id: EventId.make(`event:provider-native-stop:${nested.subagent.id}:subagent`), + type: "subagent.updated", + threadId: first.childThread.id, + nodeId: nested.subagent.id, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: nested.subagent, + }); + seededEvents.push({ + id: EventId.make(`event:provider-native-stop:${nestedOnTerminal.id}:subagent`), + type: "subagent.updated", + threadId: terminal.childThread.id, + nodeId: nestedOnTerminal.id, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: nestedOnTerminal, + }); + yield* eventSink.write({ events: seededEvents }); + + const terminalChildProjection = yield* orchestrator.getThreadProjection( + terminal.childThread.id, + ); + const terminalChildFullShell = threadShellFromProjection(terminalChildProjection); + const terminalChildSqlShell = yield* orchestrator.getThreadShell(terminal.childThread.id); + assert.isTrue(terminalChildFullShell.hasInterruptibleProviderNativeBackgroundWork); + assert.equal( + terminalChildSqlShell?.hasInterruptibleProviderNativeBackgroundWork, + terminalChildFullShell.hasInterruptibleProviderNativeBackgroundWork, + "SQL and full projections must expose directly-owned nested provider-native work", + ); + const nestedOnlyDispatch = yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("cmd:provider-native-stop:terminal-nested"), + threadId: terminal.childThread.id, + intent: "provider_native_only", + }); + assert.deepEqual( + nestedOnlyDispatch.storedEvents + .filter((stored) => stored.event.type === "provider-turn.interrupt-requested") + .map((stored) => + stored.event.type === "provider-turn.interrupt-requested" + ? stored.event.payload.providerTurnId + : null, + ), + [nested.providerTurn.id], + ); + const firstChildProjection = yield* orchestrator.getThreadProjection(first.childThread.id); + const firstChildFullShell = threadShellFromProjection(firstChildProjection); + const firstChildSqlShell = yield* orchestrator.getThreadShell(first.childThread.id); + assert.isTrue(firstChildFullShell.hasInterruptibleProviderNativeBackgroundWork); + assert.equal( + firstChildSqlShell?.hasInterruptibleProviderNativeBackgroundWork, + firstChildFullShell.hasInterruptibleProviderNativeBackgroundWork, + "a provider-native child owns its own running provider turn", + ); + + const parentShell = yield* orchestrator.getThreadShell(parentThreadId); + assert.isTrue(parentShell?.hasInterruptibleProviderNativeBackgroundWork); + + const firstCommandId = CommandId.make("cmd:provider-native-stop:first"); + const firstDispatch = yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: firstCommandId, + threadId: parentThreadId, + intent: "provider_native_only", + }); + assert.deepEqual( + firstDispatch.storedEvents + .map((stored) => + stored.event.type === "provider-turn.interrupt-requested" + ? stored.event.payload.providerTurnId + : null, + ) + .filter((providerTurnId): providerTurnId is ProviderTurnId => providerTurnId !== null), + [first.providerTurn.id, second.providerTurn.id], + ); + for (const stored of firstDispatch.storedEvents) { + if (stored.event.type !== "provider-turn.interrupt-requested") continue; + assert.equal(stored.event.threadId, parentThreadId); + assert.equal(stored.event.nodeId, undefined); + } + const firstEffects = yield* sql<{ readonly effect_id: string }>` + SELECT effect_id + FROM orchestration_v2_effect_outbox + WHERE command_id = ${firstCommandId} + ORDER BY effect_id ASC + `; + assert.deepEqual( + firstEffects.map((effect) => effect.effect_id), + [ + `effect:${firstCommandId}:provider-turn.interrupt:${first.providerTurn.id}`, + `effect:${firstCommandId}:provider-turn.interrupt:${second.providerTurn.id}`, + ], + ); + + const replayDispatch = yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: firstCommandId, + threadId: parentThreadId, + intent: "provider_native_only", + }); + assert.deepEqual(replayDispatch.storedEvents, firstDispatch.storedEvents); + const replayEffects = yield* sql<{ readonly effect_id: string }>` + SELECT effect_id + FROM orchestration_v2_effect_outbox + WHERE command_id = ${firstCommandId} + ORDER BY effect_id ASC + `; + assert.deepEqual(replayEffects, firstEffects); + + const retryCommandId = CommandId.make("cmd:provider-native-stop:retry"); + const retryDispatch = yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: retryCommandId, + threadId: parentThreadId, + intent: "provider_native_only", + }); + assert.equal( + retryDispatch.storedEvents.filter( + (stored) => stored.event.type === "provider-turn.interrupt-requested", + ).length, + 2, + ); + const retryEffects = yield* sql<{ readonly effect_id: string }>` + SELECT effect_id + FROM orchestration_v2_effect_outbox + WHERE command_id = ${retryCommandId} + ORDER BY effect_id ASC + `; + assert.deepEqual( + retryEffects.map((effect) => effect.effect_id), + [ + `effect:${retryCommandId}:provider-turn.interrupt:${first.providerTurn.id}`, + `effect:${retryCommandId}:provider-turn.interrupt:${second.providerTurn.id}`, + ], + ); + + const missingProviderInstanceId = ProviderInstanceId.make("provider-native-stop-missing"); + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:provider-native-stop:second:unresolvable"), + type: "provider-thread.updated", + threadId: second.childThread.id, + providerInstanceId: missingProviderInstanceId, + occurredAt: now, + payload: { ...second.providerThread, providerInstanceId: missingProviderInstanceId }, + }, + ], + }); + const atomicFailureCommandId = CommandId.make("cmd:provider-native-stop:atomic-failure"); + const atomicFailure = yield* orchestrator + .dispatch({ + type: "run.interrupt", + commandId: atomicFailureCommandId, + threadId: parentThreadId, + intent: "provider_native_only", + }) + .pipe(Effect.flip); + assert.equal(atomicFailure._tag, "OrchestratorProviderAdapterError"); + const atomicEffects = yield* sql<{ readonly effect_id: string }>` + SELECT effect_id + FROM orchestration_v2_effect_outbox + WHERE command_id = ${atomicFailureCommandId} + `; + assert.deepEqual(atomicEffects, []); + + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:provider-native-stop:second:restored"), + type: "provider-thread.updated", + threadId: second.childThread.id, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: second.providerThread, + }, + ], + }); + const afterFailedCommandId = CommandId.make("cmd:provider-native-stop:after-failed"); + const afterFailedDispatch = yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: afterFailedCommandId, + threadId: parentThreadId, + intent: "provider_native_only", + }); + assert.deepEqual( + afterFailedDispatch.storedEvents + .filter((stored) => stored.event.type === "provider-turn.interrupt-requested") + .map((stored) => + stored.event.type === "provider-turn.interrupt-requested" + ? stored.event.payload.providerTurnId + : null, + ), + [first.providerTurn.id, second.providerTurn.id], + ); + const afterFailedEffects = yield* sql<{ readonly effect_id: string }>` + SELECT effect_id + FROM orchestration_v2_effect_outbox + WHERE command_id = ${afterFailedCommandId} + ORDER BY effect_id ASC + `; + assert.deepEqual( + afterFailedEffects.map((effect) => effect.effect_id), + [ + `effect:${afterFailedCommandId}:provider-turn.interrupt:${first.providerTurn.id}`, + `effect:${afterFailedCommandId}:provider-turn.interrupt:${second.providerTurn.id}`, + ], + ); + + const childDispatch = yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("cmd:provider-native-stop:child"), + threadId: first.childThread.id, + intent: "provider_native_only", + }); + assert.deepEqual( + childDispatch.storedEvents + .filter((stored) => stored.event.type === "provider-turn.interrupt-requested") + .map((stored) => + stored.event.type === "provider-turn.interrupt-requested" + ? stored.event.payload.providerTurnId + : null, + ), + [first.providerTurn.id], + ); + + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:provider-native-stop:first:terminal"), + type: "provider-turn.updated", + threadId: first.childThread.id, + nodeId: first.providerTurn.nodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { ...first.providerTurn, status: "interrupted", completedAt: now }, + }, + { + id: EventId.make("event:provider-native-stop:first:subagent-terminal"), + type: "subagent.updated", + threadId: parentThreadId, + nodeId: first.subagent.id, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { ...first.subagent, status: "interrupted", completedAt: now, updatedAt: now }, + }, + ], + }); + + const nestedAfterOwnTurnDispatch = yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("cmd:provider-native-stop:child-nested-after-own"), + threadId: first.childThread.id, + intent: "provider_native_only", + }); + assert.deepEqual( + nestedAfterOwnTurnDispatch.storedEvents + .filter((stored) => stored.event.type === "provider-turn.interrupt-requested") + .map((stored) => + stored.event.type === "provider-turn.interrupt-requested" + ? stored.event.payload.providerTurnId + : null, + ), + [nested.providerTurn.id], + ); + + const afterCompletion = yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("cmd:provider-native-stop:after-completion"), + threadId: parentThreadId, + intent: "provider_native_only", + }); + assert.deepEqual( + afterCompletion.storedEvents + .filter((stored) => stored.event.type === "provider-turn.interrupt-requested") + .map((stored) => + stored.event.type === "provider-turn.interrupt-requested" + ? stored.event.payload.providerTurnId + : null, + ), + [second.providerTurn.id], + ); + + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:provider-native-stop:second:terminal"), + type: "provider-turn.updated", + threadId: second.childThread.id, + nodeId: second.providerTurn.nodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { ...second.providerTurn, status: "completed", completedAt: now }, + }, + { + id: EventId.make("event:provider-native-stop:second:subagent-terminal"), + type: "subagent.updated", + threadId: parentThreadId, + nodeId: second.subagent.id, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { ...second.subagent, status: "completed", completedAt: now, updatedAt: now }, + }, + ], + }); + const noOp = yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("cmd:provider-native-stop:no-op"), + threadId: parentThreadId, + intent: "provider_native_only", + }); + assert.equal(noOp.storedEvents[0]?.event.type, "run.interrupt-noop"); + if (noOp.storedEvents[0]?.event.type === "run.interrupt-noop") { + assert.equal( + noOp.storedEvents[0].event.payload.reason, + "All provider-native background targets completed before interruption dispatch.", + ); + } + const repeatedNoOp = yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("cmd:provider-native-stop:no-op-retry"), + threadId: parentThreadId, + intent: "provider_native_only", + }); + assert.equal(repeatedNoOp.storedEvents[0]?.event.type, "run.interrupt-noop"); + + const staleSubagents = [ + staleNullProviderThread, + staleMissingChild, + staleNoRunningTurn, + missingProviderRow.subagent, + nullProviderSession.subagent, + ]; + yield* eventSink.write({ + events: staleSubagents.map((subagent, index) => ({ + id: EventId.make(`event:provider-native-stop:stale:${index}:terminal`), + type: "subagent.updated" as const, + threadId: parentThreadId, + nodeId: subagent.id, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...subagent, + status: "interrupted" as const, + completedAt: now, + updatedAt: now, + }, + })), + }); + const settledParentShell = yield* orchestrator.getThreadShell(parentThreadId); + assert.isFalse(settledParentShell?.hasInterruptibleProviderNativeBackgroundWork); + }), + ); + + it.effect( + "keeps provider-native-only Stop off a root run that appears after the no-run read", + () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const eventSink = yield* EventSinkV2; + const parentThreadId = ThreadId.make("thread:provider-native-stop-race-parent"); + const projectId = ProjectId.make("project:provider-native-stop-race"); + const now = DateTime.makeUnsafe("2026-08-01T12:00:00.000Z"); + + yield* orchestrator.dispatch( + createThreadCommand({ + commandId: "cmd:provider-native-stop-race:create", + threadId: parentThreadId, + projectId, + }), + ); + const parent = (yield* orchestrator.getThreadProjection(parentThreadId)).thread; + const childThreadId = ThreadId.make("thread:provider-native-stop-race-child"); + const providerThreadId = ProviderThreadId.make("provider-thread:provider-native-stop-race"); + const providerTurnId = ProviderTurnId.make("provider-turn:provider-native-stop-race"); + const nodeId = NodeId.make("node:provider-native-stop-race"); + const childThread = makeProviderChildThread({ + parent, + childThreadId, + providerThreadId, + now, + }); + const providerThread = { + ...makeProviderThread({ + id: providerThreadId, + threadId: childThreadId, + now, + }), + providerSessionId: ProviderSessionId.make("provider-session:provider-native-stop-race"), + ownerNodeId: nodeId, + status: "active" as const, + appThreadId: childThreadId, + }; + const providerTurn = makeProviderChildTurn({ + providerThreadId, + providerTurnId, + nodeId, + now, + status: "running", + }); + const subagent = makeProviderNativeSubagent({ + parentThreadId, + parentNodeId: nodeId, + providerThreadId, + childThreadId, + now, + status: "running", + }); + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:provider-native-stop-race:thread"), + type: "thread.created", + threadId: childThreadId, + occurredAt: now, + payload: childThread, + }, + { + id: EventId.make("event:provider-native-stop-race:provider-thread"), + type: "provider-thread.updated", + threadId: childThreadId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: providerThread, + }, + { + id: EventId.make("event:provider-native-stop-race:provider-turn"), + type: "provider-turn.updated", + threadId: childThreadId, + nodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: providerTurn, + }, + { + id: EventId.make("event:provider-native-stop-race:subagent"), + type: "subagent.updated", + threadId: parentThreadId, + nodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: subagent, + }, + ], + }); + + const shellBeforeRootRace = yield* orchestrator.getThreadShell(parentThreadId); + assert.isTrue(shellBeforeRootRace?.hasInterruptibleProviderNativeBackgroundWork); + + const rootRunId = RunId.make("run:provider-native-stop-race-root"); + const rootRun = makeRun({ + runId: rootRunId, + threadId: parentThreadId, + status: "running", + now, + }); + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:provider-native-stop-race:root-run"), + type: "run.created", + threadId: parentThreadId, + runId: rootRunId, + ...(rootRun.rootNodeId === null ? {} : { nodeId: rootRun.rootNodeId }), + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: rootRun, + }, + ], + }); + + const dispatch = yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("cmd:provider-native-stop-race:dispatch"), + threadId: parentThreadId, + intent: "provider_native_only", + }); + assert.deepEqual( + dispatch.storedEvents.map((stored) => stored.event.type), + ["provider-turn.interrupt-requested"], + ); + assert.isFalse( + dispatch.storedEvents.some( + (stored) => + stored.event.type === "turn-item.updated" && + stored.event.payload.type === "run_interrupt_request", + ), + ); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 55eb2ecc8b3..55d5c812dae 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1324,16 +1324,32 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : (providerSwitchPlan?.releaseProviderSessionIds ?? []), ); if (detachSessionIds.size > 0) { - const liveSessions = projection.providerSessions.filter( + const sessionsToDetach = projection.providerSessions.filter( (session) => detachSessionIds.has(session.id) && - session.status !== "stopped" && - session.status !== "error", + (command.type === "thread.delete" || + (session.status !== "stopped" && session.status !== "error")), ); yield* Effect.forEach( - liveSessions, + sessionsToDetach, (session) => Effect.gen(function* () { + // Pending and materialized projection rows can share one native id. + const providerThreads = Array.from( + new Map( + projection.providerThreads.flatMap((thread) => { + const nativeThreadRef = thread.nativeThreadRef; + if (thread.providerSessionId !== session.id || nativeThreadRef === null) + return []; + return [ + [ + JSON.stringify([nativeThreadRef.driver, nativeThreadRef.nativeId]), + thread, + ] as const, + ]; + }), + ).values(), + ); yield* emit( events, command, @@ -1381,6 +1397,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.type === "thread.archive" || command.type === "thread.delete" ? { revokeMcpCredential: true } : {}), + ...(command.type === "thread.delete" + ? { + deleteProviderThread: true, + providerInstanceId: session.providerInstanceId, + providerSession: session, + providerThreads, + } + : {}), }, } satisfies PendingOrchestrationEffectV2; yield* Ref.update(effects, (existing) => [...existing, pendingEffect]); @@ -5005,6 +5029,101 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); + const providerNativeInterruptTargets = (projection: OrchestrationV2ThreadProjection) => + Effect.gen(function* () { + // A provider-native child's own live turn gets the first Stop. Once it + // settles, the same path targets its direct descendants. This preserves + // the active-turn boundary without leaving nested work unreachable. + const targetThreads: Array<{ + readonly childThreadId: ThreadId; + readonly providerThreadId: OrchestrationV2ProviderThread["id"]; + }> = []; + const isProviderNativeChild = + projection.thread.creationSource === "provider" && + projection.thread.lineage.relationshipToParent === "subagent"; + + const ownProviderThreadId = isProviderNativeChild + ? projection.thread.activeProviderThreadId + : null; + const ownTurnIsRunning = + ownProviderThreadId !== null && + projection.providerTurns.some( + (candidate) => + candidate.providerThreadId === ownProviderThreadId && candidate.status === "running", + ); + + if (ownProviderThreadId !== null && ownTurnIsRunning) { + targetThreads.push({ + childThreadId: projection.thread.id, + providerThreadId: ownProviderThreadId, + }); + } else { + for (const subagent of projection.subagents) { + if ( + subagent.threadId !== projection.thread.id || + subagent.origin !== "provider_native" || + subagent.status !== "running" || + subagent.childThreadId === null + ) { + continue; + } + const providerThreadId = subagent.providerThreadId; + if (providerThreadId === null) continue; + targetThreads.push({ + childThreadId: subagent.childThreadId, + providerThreadId, + }); + } + } + + const targets = new Map< + string, + { + readonly threadId: ThreadId; + readonly providerThread: OrchestrationV2ProviderThread; + readonly providerTurn: OrchestrationV2ProviderTurn; + } + >(); + for (const targetThread of targetThreads) { + const childProjection = yield* projectionStore + .getThreadProjection(targetThread.childThreadId) + .pipe( + Effect.catchTags({ + ProjectionStoreThreadNotFoundError: () => Effect.succeed(null), + }), + Effect.mapError( + (cause) => + new OrchestratorProjectionError({ + threadId: targetThread.childThreadId, + cause, + }), + ), + ); + if (childProjection === null) continue; + + const providerTurn = childProjection.providerTurns + .filter( + (candidate) => + candidate.providerThreadId === targetThread.providerThreadId && + candidate.status === "running", + ) + .toSorted((left, right) => right.ordinal - left.ordinal)[0]; + if (providerTurn === undefined) continue; + const providerThread = childProjection.providerThreads.find( + (candidate) => + candidate.id === targetThread.providerThreadId && + candidate.appThreadId === childProjection.thread.id, + ); + if (providerThread === undefined || providerThread.providerSessionId === null) continue; + targets.set(String(providerTurn.id), { + threadId: childProjection.thread.id, + providerThread, + providerTurn, + }); + } + return Array.from(targets.values()); + }); + const dispatchRunInterrupt = ( command: Extract, events: Ref.Ref>, @@ -5012,7 +5131,107 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) => Effect.gen(function* () { const projection = yield* loadProjectionForCommand(command); - const run = projection.runs.find((candidate) => candidate.id === command.runId); + const providerNativeOnly = command.intent === "provider_native_only"; + let run: OrchestrationV2Run | undefined; + if (!providerNativeOnly) { + run = + command.runId === undefined + ? projection.runs + .filter( + (candidate) => + candidate.status === "preparing" || + candidate.status === "starting" || + candidate.status === "running", + ) + .toSorted((left, right) => right.ordinal - left.ordinal)[0] + : projection.runs.find((candidate) => candidate.id === command.runId); + } + + if (run === undefined && (command.runId === undefined || providerNativeOnly)) { + const targets = yield* providerNativeInterruptTargets(projection); + const now = yield* DateTime.now; + const emitEvent = emit(events, command); + if (targets.length === 0) { + // EventSink.commitCommand requires at least one domain event, so a + // successful zero-target receipt must remain durable and replayable. + yield* emitEvent({ + type: "run.interrupt-noop", + threadId: command.threadId, + occurredAt: now, + payload: { + reason: + "All provider-native background targets completed before interruption dispatch.", + }, + }); + return undefined; + } + + const authorizedTargets = yield* Effect.forEach(targets, (target) => + Effect.gen(function* () { + const providerSessionId = target.providerThread.providerSessionId; + if (providerSessionId === null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Provider turn ${target.providerTurn.id} has no provider session target.`, + }); + } + const capabilities = yield* providerAdapters + .get(target.providerThread.providerInstanceId) + .pipe( + Effect.flatMap((adapter) => adapter.getCapabilities()), + Effect.mapError( + (cause) => + new OrchestratorProviderAdapterError({ + commandId: command.commandId, + providerInstanceId: target.providerThread.providerInstanceId, + cause, + }), + ), + ); + yield* enforceCommandPolicy(command)( + commandPolicy.ensureInterrupt({ + commandId: command.commandId, + threadId: command.threadId, + providerInstanceId: target.providerThread.providerInstanceId, + capabilities, + }), + ); + return { target, providerSessionId }; + }), + ); + + for (const { target, providerSessionId } of authorizedTargets) { + yield* emitEvent({ + type: "provider-turn.interrupt-requested", + threadId: command.threadId, + driver: target.providerThread.driver, + providerInstanceId: target.providerThread.providerInstanceId, + occurredAt: now, + payload: { + targetThreadId: target.threadId, + providerThreadId: target.providerThread.id, + providerTurnId: target.providerTurn.id, + reason: command.reason ?? null, + }, + }); + yield* Ref.update(effects, (existing) => [ + ...existing, + { + id: `effect:${command.commandId}:provider-turn.interrupt:${target.providerTurn.id}`, + commandId: command.commandId, + threadId: target.threadId, + request: { + type: "provider-turn.interrupt", + providerSessionId, + providerThreadId: target.providerThread.id, + providerTurnId: target.providerTurn.id, + }, + } satisfies PendingOrchestrationEffectV2, + ]); + } + return undefined; + } const rootNode = run?.rootNodeId === null ? undefined @@ -5029,7 +5248,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Run ${command.runId} is not interruptible.`, + cause: `Run ${command.runId ?? "without a run id"} is not interruptible.`, }); } @@ -5068,7 +5287,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Run ${command.runId} has no active attempt to interrupt.`, + cause: `Run ${run.id} has no active attempt to interrupt.`, }); } const interruptResultItem: OrchestrationV2TurnItem = { @@ -5180,7 +5399,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Run ${command.runId} is not interruptible.`, + cause: `Run ${run.id} is not interruptible.`, }); } if (providerThread.providerSessionId === null) { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 645283705d5..54c864825a7 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -284,6 +284,8 @@ export function applyToProjection( checkpoints: upsertById(base.checkpoints, event.payload), }; case "checkpoint.rollback-requested": + case "provider-turn.interrupt-requested": + case "run.interrupt-noop": return base; case "context-handoff.updated": return { @@ -410,6 +412,7 @@ type ShellThreadRow = { readonly latest_message_payload_json: string | null; readonly latest_user_message_at: string | null; readonly has_actionable_proposed_plan: number; + readonly has_interruptible_provider_native_background_work: number; readonly item_count: number; readonly runless_item_count: number; }; @@ -791,6 +794,35 @@ export function threadShellFromProjection( (left, right) => DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), )[0] ?? null; + // This is a projection-local Stop hint. Dispatch revalidates each linked + // child turn before emitting an interrupt, so a rolled-back parent can keep + // the hint while a genuinely running native child drains. Normal terminal + // subagent events remove the hint once that child is no longer running. + const hasInterruptibleProviderNativeBackgroundWork = + projection.thread.creationSource === "provider" && + projection.thread.lineage.relationshipToParent === "subagent" + ? (projection.thread.activeProviderThreadId !== null && + projection.providerTurns.some( + (turn) => + turn.providerThreadId === projection.thread.activeProviderThreadId && + turn.status === "running", + )) || + projection.subagents.some( + (subagent) => + subagent.threadId === projection.thread.id && + subagent.origin === "provider_native" && + subagent.status === "running" && + subagent.childThreadId !== null && + subagent.providerThreadId !== null, + ) + : projection.subagents.some( + (subagent) => + subagent.threadId === projection.thread.id && + subagent.origin === "provider_native" && + subagent.status === "running" && + subagent.childThreadId !== null && + subagent.providerThreadId !== null, + ); return { createdBy: projection.thread.createdBy, creationSource: projection.thread.creationSource, @@ -837,6 +869,7 @@ export function threadShellFromProjection( hasActionableProposedPlan: projection.plans.some( (plan) => plan.kind === "proposed_plan" && plan.status === "active", ), + hasInterruptibleProviderNativeBackgroundWork, itemCount: activeLocalTurnItems(projection).length, visibleItemCount: projection.visibleTurnItems.length, createdAt: projection.thread.createdAt, @@ -869,6 +902,7 @@ type ShellThreadState = { readonly latestVisibleMessage: OrchestrationV2ConversationMessage | null; readonly latestUserMessageAt: DateTime.Utc | null; readonly hasActionableProposedPlan: boolean; + readonly hasInterruptibleProviderNativeBackgroundWork: boolean; readonly itemCount: number; readonly runlessItemCount: number; readonly updatedAt: OrchestrationV2ThreadProjection["updatedAt"]; @@ -1006,6 +1040,8 @@ function shellFromState(input: { }, latestUserMessageAt: input.state.latestUserMessageAt, hasActionableProposedPlan: input.state.hasActionableProposedPlan, + hasInterruptibleProviderNativeBackgroundWork: + input.state.hasInterruptibleProviderNativeBackgroundWork, itemCount: input.state.itemCount, visibleItemCount: input.visibleItemCount, createdAt: input.state.thread.createdAt, @@ -1715,6 +1751,8 @@ export const layer: Layer.Layer = break; } case "checkpoint.rollback-requested": + case "provider-turn.interrupt-requested": + case "run.interrupt-noop": break; case "context-handoff.updated": { const payloadJson = yield* encodeContextHandoffPayload(event.payload); @@ -2201,6 +2239,34 @@ export const layer: Layer.Layer = AND plan.kind = 'proposed_plan' AND plan.status = 'active' ) AS has_actionable_proposed_plan, + CASE + WHEN json_extract(t.payload_json, '$.creationSource') = 'provider' + AND json_extract(t.payload_json, '$.lineage.relationshipToParent') = 'subagent' + THEN EXISTS ( + SELECT 1 + FROM orchestration_v2_projection_provider_turns provider_turn + WHERE provider_turn.thread_id = t.thread_id + AND provider_turn.provider_thread_id = json_extract(t.payload_json, '$.activeProviderThreadId') + AND provider_turn.status = 'running' + ) OR EXISTS ( + SELECT 1 + FROM orchestration_v2_projection_subagents subagent + WHERE subagent.thread_id = t.thread_id + AND subagent.origin = 'provider_native' + AND subagent.status = 'running' + AND subagent.provider_thread_id IS NOT NULL + AND subagent.child_thread_id IS NOT NULL + ) + ELSE EXISTS ( + SELECT 1 + FROM orchestration_v2_projection_subagents subagent + WHERE subagent.thread_id = t.thread_id + AND subagent.origin = 'provider_native' + AND subagent.status = 'running' + AND subagent.provider_thread_id IS NOT NULL + AND subagent.child_thread_id IS NOT NULL + ) + END AS has_interruptible_provider_native_background_work, ( SELECT COUNT(*) FROM orchestration_v2_projection_turn_items i @@ -2287,6 +2353,8 @@ export const layer: Layer.Layer = row.latest_message_payload_json === null ? null : yield* decodeMessagePayload(row.latest_message_payload_json); + const hasInterruptibleProviderNativeBackgroundWork = + row.has_interruptible_provider_native_background_work === 1; return { thread, latestRunId: row.latest_run_id === null ? null : RunId.make(row.latest_run_id), @@ -2312,6 +2380,7 @@ export const layer: Layer.Layer = ? null : DateTime.makeUnsafe(row.latest_user_message_at), hasActionableProposedPlan: row.has_actionable_proposed_plan === 1, + hasInterruptibleProviderNativeBackgroundWork, itemCount: row.item_count, runlessItemCount: row.runless_item_count, updatedAt: thread.updatedAt, diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index a86fb0b48f6..ebe020da9fb 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -485,6 +485,14 @@ export interface ProviderAdapterV2SessionRuntime { * here so the session manager defers idle release while it is pending. */ readonly hasPendingBackgroundWork?: Effect.Effect; + /** + * Per-provider-thread pending work for root-run ingestion stop gates. When + * present, RunExecutionService uses only this probe so sibling native + * threads cannot pin an unrelated root subscription open. + */ + readonly hasPendingBackgroundWorkForThread?: ( + providerThread: OrchestrationV2ProviderThread, + ) => Effect.Effect; readonly ensureThread: ( input: ProviderAdapterV2EnsureThreadInput, ) => Effect.Effect; @@ -494,6 +502,13 @@ export interface ProviderAdapterV2SessionRuntime { readonly modelSelection?: ModelSelection; readonly runtimePolicy?: ProviderAdapterV2RuntimePolicy; }) => Effect.Effect; + /** + * Remove the provider-native thread when the owning application thread is + * permanently deleted. Archive and ordinary detach flows never call this. + */ + readonly deleteThread?: ( + providerThread: OrchestrationV2ProviderThread, + ) => Effect.Effect; readonly startTurn: ( input: ProviderAdapterV2TurnInput, ) => Effect.Effect; @@ -520,6 +535,14 @@ export interface ProviderAdapterV2SessionRuntime { export interface ProviderAdapterV2Shape { readonly instanceId: ProviderInstanceId; readonly driver: ProviderDriverKind; + /** + * Remove a provider-native thread when no managed provider session remains + * alive. The caller supplies a scope for any temporary provider connection. + */ + readonly deleteDetachedThread?: (input: { + readonly providerSession: OrchestrationV2ProviderSession; + readonly providerThread: OrchestrationV2ProviderThread; + }) => Effect.Effect; readonly getCapabilities: () => Effect.Effect< OrchestrationV2ProviderCapabilities, ProviderAdapterV2Error diff --git a/apps/server/src/orchestration-v2/ProviderInteractionModeReflectionService.test.ts b/apps/server/src/orchestration-v2/ProviderInteractionModeReflectionService.test.ts new file mode 100644 index 00000000000..48c29f154ca --- /dev/null +++ b/apps/server/src/orchestration-v2/ProviderInteractionModeReflectionService.test.ts @@ -0,0 +1,317 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + ProviderDriverKind, + ThreadId, + type OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; +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 Queue from "effect/Queue"; +import * as TestClock from "effect/testing/TestClock"; + +import { + ProviderInteractionModeReflections, + layer as reflectionsLayer, +} from "./ProviderInteractionModeReflections.ts"; +import { OrchestratorDispatchError, OrchestratorProjectionError } from "./Orchestrator.ts"; +import { workerLive } from "./ProviderInteractionModeReflectionService.ts"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; + +const threadId = ThreadId.make("thread-interaction-mode-reflection"); +const driver = ProviderDriverKind.make("reflection-test"); + +function reflectionCommandId(threadId: ThreadId, dedupeKey: string): string { + return `interaction-mode-reflection:${threadId}:${dedupeKey}`; +} + +function projectionWith(input: { + readonly archivedAt?: string | null; + readonly interactionMode: "default" | "plan"; +}): OrchestrationV2ThreadProjection { + return { + thread: { + archivedAt: input.archivedAt ?? null, + interactionMode: input.interactionMode, + }, + } as unknown as OrchestrationV2ThreadProjection; +} + +/** + * The worker drains sequentially, so a probe request whose dispatch is awaited + * proves every earlier request finished without dispatching. + */ +function testLayer(input: { + readonly dispatched: Queue.Queue; + readonly projections: Array; + readonly readProjection?: () => Effect.Effect< + OrchestrationV2ThreadProjection, + OrchestratorProjectionError + >; + readonly dispatch?: ThreadManagementService["Service"]["dispatch"]; +}) { + const threads = Layer.mock(ThreadManagementService)({ + getThreadProjection: () => + input.readProjection?.() ?? + Effect.sync(() => { + const next = input.projections.shift(); + if (next === undefined) throw new Error("unexpected extra projection read"); + return next; + }), + dispatch: (command) => + input.dispatch?.(command) ?? + Queue.offer(input.dispatched, command).pipe(Effect.as({} as never)), + }); + const worker = workerLive.pipe(Layer.provide(Layer.merge(reflectionsLayer, threads))); + return Layer.merge(reflectionsLayer, worker); +} + +const probeReflection = { + threadId, + driver, + interactionMode: "default", + dedupeKey: "opencode2:probe", +} as const; + +describe("ProviderInteractionModeReflectionService", () => { + it.effect("applies a native plan exit as thread.interaction-mode.set", () => { + return Effect.gen(function* () { + const dispatched = yield* Queue.unbounded(); + yield* Effect.gen(function* () { + const requests = yield* ProviderInteractionModeReflections; + yield* requests.offer({ + threadId, + driver, + interactionMode: "default", + dedupeKey: "opencode2:evt_1", + }); + const command = (yield* Queue.take(dispatched)) as { + readonly type: string; + readonly commandId: string; + readonly threadId: string; + readonly interactionMode: string; + }; + assert.equal(command.type, "thread.interaction-mode.set"); + assert.equal(command.threadId, threadId); + assert.equal(command.interactionMode, "default"); + // Replayed native events must dedupe through command receipts. + assert.equal(command.commandId, reflectionCommandId(threadId, "opencode2:evt_1")); + }).pipe( + Effect.provide( + testLayer({ + dispatched, + projections: [projectionWith({ interactionMode: "plan" })], + }), + ), + Effect.scoped, + ); + }); + }); + + it.effect("retries a transient dispatch failure before completing the reflection", () => { + return Effect.gen(function* () { + const dispatched = yield* Queue.unbounded(); + const firstDispatch = Deferred.makeUnsafe(); + let dispatchAttempts = 0; + yield* Effect.gen(function* () { + const requests = yield* ProviderInteractionModeReflections; + yield* requests.offer({ + threadId, + driver, + interactionMode: "default", + dedupeKey: "opencode2:retry-projection", + }); + const clockDriver = yield* Effect.gen(function* () { + yield* Deferred.await(firstDispatch); + for (let attempt = 0; attempt < 3; attempt += 1) { + yield* Effect.yieldNow; + yield* TestClock.adjust("100 millis"); + } + }).pipe(Effect.forkChild); + const command = (yield* Queue.take(dispatched)) as { + readonly commandId: string; + readonly interactionMode: string; + }; + assert.equal( + command.commandId, + reflectionCommandId(threadId, "opencode2:retry-projection"), + ); + assert.equal(command.interactionMode, "default"); + assert.equal(dispatchAttempts, 2); + yield* Fiber.join(clockDriver); + }).pipe( + Effect.provide( + testLayer({ + dispatched, + projections: [], + readProjection: () => Effect.succeed(projectionWith({ interactionMode: "plan" })), + dispatch: (command) => + Effect.sync(() => { + dispatchAttempts += 1; + if (dispatchAttempts === 1) { + Deferred.doneUnsafe(firstDispatch, Effect.void); + } + return dispatchAttempts; + }).pipe( + Effect.flatMap((attempt) => + attempt === 1 + ? Effect.fail( + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: new Error("temporary dispatch failure"), + }), + ) + : Queue.offer(dispatched, command).pipe(Effect.as({} as never)), + ), + ), + }), + ), + Effect.scoped, + ); + }); + }); + + it.effect("continues draining after a defective reflection", () => { + return Effect.gen(function* () { + const dispatched = yield* Queue.unbounded(); + let projectionReads = 0; + yield* Effect.gen(function* () { + const requests = yield* ProviderInteractionModeReflections; + yield* requests.offer({ + threadId, + driver, + interactionMode: "default", + dedupeKey: "opencode2:defective", + }); + yield* requests.offer(probeReflection); + + const command = (yield* Queue.take(dispatched)) as { readonly commandId: string }; + assert.equal(command.commandId, reflectionCommandId(threadId, "opencode2:probe")); + assert.equal(projectionReads, 2); + }).pipe( + Effect.provide( + testLayer({ + dispatched, + projections: [], + readProjection: () => + Effect.sync(() => { + projectionReads += 1; + if (projectionReads === 1) throw new Error("unexpected projection defect"); + return projectionWith({ interactionMode: "plan" }); + }), + }), + ), + Effect.scoped, + ); + }); + }); + + it.effect("dispatches matching provider event keys independently per thread", () => { + return Effect.gen(function* () { + const secondThreadId = ThreadId.make("thread-interaction-mode-reflection-second"); + const dispatched = yield* Queue.unbounded(); + yield* Effect.gen(function* () { + const requests = yield* ProviderInteractionModeReflections; + const dedupeKey = "opencode2:shared-event"; + yield* requests.offer({ + threadId, + driver, + interactionMode: "default", + dedupeKey, + }); + yield* requests.offer({ + threadId: secondThreadId, + driver, + interactionMode: "default", + dedupeKey, + }); + + const first = (yield* Queue.take(dispatched)) as { readonly commandId: string }; + const second = (yield* Queue.take(dispatched)) as { readonly commandId: string }; + assert.deepEqual( + [first.commandId, second.commandId], + [ + reflectionCommandId(threadId, dedupeKey), + reflectionCommandId(secondThreadId, dedupeKey), + ], + ); + }).pipe( + Effect.provide( + testLayer({ + dispatched, + projections: [ + projectionWith({ interactionMode: "plan" }), + projectionWith({ interactionMode: "plan" }), + ], + }), + ), + Effect.scoped, + ); + }); + }); + + it.effect("skips a thread already in the reflected mode", () => { + return Effect.gen(function* () { + const dispatched = yield* Queue.unbounded(); + yield* Effect.gen(function* () { + const requests = yield* ProviderInteractionModeReflections; + yield* requests.offer({ + threadId, + driver, + interactionMode: "plan", + dedupeKey: "opencode2:evt_2", + }); + yield* requests.offer(probeReflection); + const command = (yield* Queue.take(dispatched)) as { readonly commandId: string }; + assert.equal(command.commandId, reflectionCommandId(threadId, "opencode2:probe")); + assert.equal(yield* Queue.size(dispatched), 0); + }).pipe( + Effect.provide( + testLayer({ + dispatched, + projections: [ + projectionWith({ interactionMode: "plan" }), + projectionWith({ interactionMode: "plan" }), + ], + }), + ), + Effect.scoped, + ); + }); + }); + + it.effect("skips archived threads", () => { + return Effect.gen(function* () { + const dispatched = yield* Queue.unbounded(); + yield* Effect.gen(function* () { + const requests = yield* ProviderInteractionModeReflections; + yield* requests.offer({ + threadId, + driver, + interactionMode: "default", + dedupeKey: "opencode2:evt_3", + }); + yield* requests.offer(probeReflection); + const command = (yield* Queue.take(dispatched)) as { readonly commandId: string }; + assert.equal(command.commandId, reflectionCommandId(threadId, "opencode2:probe")); + assert.equal(yield* Queue.size(dispatched), 0); + }).pipe( + Effect.provide( + testLayer({ + dispatched, + projections: [ + projectionWith({ + archivedAt: "2026-07-31T00:00:00.000Z", + interactionMode: "plan", + }), + projectionWith({ interactionMode: "plan" }), + ], + }), + ), + Effect.scoped, + ); + }); + }); +}); diff --git a/apps/server/src/orchestration-v2/ProviderInteractionModeReflectionService.ts b/apps/server/src/orchestration-v2/ProviderInteractionModeReflectionService.ts new file mode 100644 index 00000000000..66b7cf8be90 --- /dev/null +++ b/apps/server/src/orchestration-v2/ProviderInteractionModeReflectionService.ts @@ -0,0 +1,70 @@ +import { CommandId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; + +import { + type ProviderInteractionModeReflection, + ProviderInteractionModeReflections, +} from "./ProviderInteractionModeReflections.ts"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; + +const MAX_REFLECTION_ATTEMPTS = 3; +const REFLECTION_RETRY_DELAY = "100 millis"; + +/** + * Drains ProviderInteractionModeReflections and applies each one as an + * ordinary `thread.interaction-mode.set` command, so the update flows through + * the same handler, event, and projection path as a user toggling the mode. + * Already-matching and archived threads are skipped; the command id derives + * from the reflection's thread and dedupe key, so a duplicated native event + * cannot apply twice. + */ +export const workerLive = Layer.effectDiscard( + Effect.gen(function* () { + const requests = yield* ProviderInteractionModeReflections; + const threads = yield* ThreadManagementService; + + const applyReflection = Effect.fn("ProviderInteractionModeReflectionService.apply")(function* ( + request: ProviderInteractionModeReflection, + ) { + const projection = yield* threads.getThreadProjection(request.threadId); + if ( + projection.thread.archivedAt !== null || + projection.thread.interactionMode === request.interactionMode + ) { + return; + } + yield* threads.dispatch({ + type: "thread.interaction-mode.set", + commandId: CommandId.make( + `interaction-mode-reflection:${request.threadId}:${request.dedupeKey}`, + ), + threadId: request.threadId, + interactionMode: request.interactionMode, + }); + }); + + const applyReflectionWithRetry = (request: ProviderInteractionModeReflection) => + applyReflection(request).pipe( + Effect.retry({ + times: MAX_REFLECTION_ATTEMPTS - 1, + schedule: Schedule.spaced(REFLECTION_RETRY_DELAY), + }), + Effect.catchCause(() => + Effect.logWarning("orchestration-v2.interaction-mode-reflection.apply-failed", { + driver: request.driver, + interactionMode: request.interactionMode, + retrying: false, + threadId: request.threadId, + }), + ), + ); + + yield* requests.take.pipe( + Effect.flatMap(applyReflectionWithRetry), + Effect.forever, + Effect.forkScoped, + ); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderInteractionModeReflections.ts b/apps/server/src/orchestration-v2/ProviderInteractionModeReflections.ts new file mode 100644 index 00000000000..7a7ea3a8e6d --- /dev/null +++ b/apps/server/src/orchestration-v2/ProviderInteractionModeReflections.ts @@ -0,0 +1,45 @@ +import { ProviderDriverKind, ProviderInteractionMode, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; + +export interface ProviderInteractionModeReflection { + readonly threadId: ThreadId; + readonly driver: ProviderDriverKind; + readonly interactionMode: ProviderInteractionMode; + /** + * Stable per-native-event key. The worker derives the command id from it, so + * a replayed or duplicated native event dedupes through command receipts + * instead of re-emitting a thread update. + */ + readonly dedupeKey: string; +} + +/** + * Adapters offer a reflection when the provider itself moves a session between + * its native plan and build modes (for example OpenCode's plan_exit flow + * switching the session to the build agent) so the thread's interaction mode + * follows reality instead of pushing the stale mode back on the next turn. + * The default reference drops requests, keeping adapter construction + * dependency-free in tests; the live layer must be shared with the + * ProviderInteractionModeReflectionService worker that drains it. + */ +export class ProviderInteractionModeReflections extends Context.Reference<{ + readonly offer: (request: ProviderInteractionModeReflection) => Effect.Effect; + readonly take: Effect.Effect; +}>("t3/orchestration-v2/ProviderInteractionModeReflections", { + defaultValue: () => ({ offer: () => Effect.void, take: Effect.never }), +}) {} + +export const layer = Layer.effect( + ProviderInteractionModeReflections, + Effect.gen(function* () { + const queue = yield* Queue.unbounded(); + return { + offer: (request: ProviderInteractionModeReflection) => + Queue.offer(queue, request).pipe(Effect.asVoid), + take: Queue.take(queue), + }; + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index b681836682e..ef5faa47895 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -89,6 +89,8 @@ const ExclusiveCapabilities: OrchestrationV2ProviderCapabilities = { interface TestProviderRuntimeState { readonly openCount: number; readonly closeCount: number; + readonly deleteCount: number; + readonly detachedDeleteCount: number; readonly interruptCount: number; readonly resumeCount: number; readonly eventQueues: ReadonlyMap>; @@ -97,6 +99,8 @@ interface TestProviderRuntimeState { const emptyState: TestProviderRuntimeState = { openCount: 0, closeCount: 0, + deleteCount: 0, + detachedDeleteCount: 0, interruptCount: 0, resumeCount: 0, eventQueues: new Map(), @@ -237,11 +241,24 @@ function makeProviderAdapter( }) => Effect.Effect; readonly hasPendingBackgroundWork?: Effect.Effect; readonly hangSessionScopeClose?: boolean; + readonly failDeleteThread?: boolean; + readonly failDetachedDeleteThread?: boolean; } = {}, ): ProviderAdapterV2Shape { return { instanceId: ProviderInstanceId.make("codex"), driver: CODEX_DRIVER, + deleteDetachedThread: () => + Ref.update(state, (current) => ({ + ...current, + detachedDeleteCount: current.detachedDeleteCount + 1, + })).pipe( + Effect.andThen( + options.failDetachedDeleteThread === true + ? unimplemented("detached native deletion failed") + : Effect.void, + ), + ), getCapabilities: () => Effect.succeed(options.capabilities ?? CodexCapabilities), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), openSession: (input) => @@ -307,6 +324,17 @@ function makeProviderAdapter( ...current, resumeCount: current.resumeCount + 1, })).pipe(Effect.as(threadInput.providerThread)), + deleteThread: () => + Ref.update(state, (current) => ({ + ...current, + deleteCount: current.deleteCount + 1, + })).pipe( + Effect.andThen( + options.failDeleteThread === true + ? unimplemented("native deletion failed") + : Effect.void, + ), + ), startTurn: () => Effect.void, steerTurn: () => Effect.void, interruptTurn: () => @@ -338,6 +366,8 @@ function makeTestLayer(input: { readonly failReleaseEventWrites?: boolean; readonly hasPendingBackgroundWork?: Effect.Effect; readonly hangSessionScopeClose?: boolean; + readonly failDeleteThread?: boolean; + readonly failDetachedDeleteThread?: boolean; }) { const configuredEventSinkLayer = input.failReleaseEventWrites ? FailingReleaseEventSinkLayer @@ -354,6 +384,10 @@ function makeTestLayer(input: { ...(input.hangSessionScopeClose === undefined ? {} : { hangSessionScopeClose: input.hangSessionScopeClose }), + ...(input.failDeleteThread === undefined ? {} : { failDeleteThread: input.failDeleteThread }), + ...(input.failDetachedDeleteThread === undefined + ? {} + : { failDetachedDeleteThread: input.failDetachedDeleteThread }), }), ); return Layer.mergeAll( @@ -1233,6 +1267,244 @@ it.effect("ProviderSessionManagerV2 terminal detach revokes the thread's MCP cre }), ); +it.effect("ProviderSessionManagerV2 deletes native threads only on permanent detach", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSinkV2; + const idAllocator = yield* IdAllocatorV2; + const manager = yield* ProviderSessionManagerV2; + const now = yield* DateTime.now; + const projectId = yield* idAllocator.allocate.project({ + fixtureName: "provider-session-manager-native-delete", + }); + const threadId = yield* idAllocator.allocate.thread({ + fixtureName: "provider-session-manager-native-delete", + projectId, + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const providerThread = makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + }); + + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId, now }), + { + id: yield* idAllocator.allocate.event({ threadId }), + type: "provider-thread.updated", + threadId, + driver: CODEX_DRIVER, + occurredAt: now, + payload: providerThread, + }, + ], + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + + yield* manager.detach({ + providerSessionId, + threadId, + detail: "Thread deleted.", + deleteProviderThread: true, + }); + + assert.equal((yield* Ref.get(state)).deleteCount, 1); + }); + + yield* effect.pipe( + Effect.provide( + makeTestLayer({ + state, + idleTimeoutMs: 1_000, + capabilities: ExclusiveCapabilities, + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 cleans up the session and MCP credential when native deletion fails", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSinkV2; + const idAllocator = yield* IdAllocatorV2; + const manager = yield* ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-delete-failure"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const providerThread = makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + }); + + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId, now }), + { + id: yield* idAllocator.allocate.event({ threadId }), + type: "provider-thread.updated", + threadId, + driver: CODEX_DRIVER, + occurredAt: now, + payload: providerThread, + }, + ], + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const token = (yield* Ref.get(mcpConfigs)) + .at(-1) + ?.authorizationHeader.replace(/^Bearer\s+/, ""); + assert.isDefined(yield* registry.resolve(token!)); + + yield* manager + .detach({ + providerSessionId, + threadId, + detail: "Thread deleted.", + deleteProviderThread: true, + revokeMcpCredential: true, + }) + .pipe(Effect.flip); + + const runtimeState = yield* Ref.get(state); + assert.equal(runtimeState.deleteCount, 1); + assert.equal(runtimeState.closeCount, 1); + assert.isUndefined(yield* registry.resolve(token!)); + assert.isUndefined(McpProviderSession.readMcpProviderSession(threadId)); + }); + + yield* effect.pipe( + Effect.provide( + makeTestLayer({ + state, + idleTimeoutMs: 1_000, + capabilities: ExclusiveCapabilities, + mcpConfigs, + failDeleteThread: true, + }), + ), + ); + }), +); + +it.effect("ProviderSessionManagerV2 deletes detached historical native threads", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const effect = Effect.gen(function* () { + const idAllocator = yield* IdAllocatorV2; + const manager = yield* ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = yield* idAllocator.allocate.thread({ + fixtureName: "provider-session-manager-historical-native-delete", + projectId: yield* idAllocator.allocate.project({ + fixtureName: "provider-session-manager-historical-native-delete", + }), + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const providerSession = makeProviderSession({ providerSessionId, now }); + const providerThread = makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + }); + + yield* manager.detach({ + providerSessionId, + threadId, + detail: "Thread deleted.", + deleteProviderThread: true, + providerInstanceId: modelSelection.instanceId, + providerSession, + providerThreads: [providerThread], + }); + + assert.equal((yield* Ref.get(state)).detachedDeleteCount, 1); + assert.equal((yield* Ref.get(state)).openCount, 0); + }); + + yield* effect.pipe( + Effect.provide( + makeTestLayer({ + state, + idleTimeoutMs: 1_000, + capabilities: ExclusiveCapabilities, + }), + ), + ); + }), +); + +it.effect("ProviderSessionManagerV2 reports detached native deletion after cleanup", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const effect = Effect.gen(function* () { + const idAllocator = yield* IdAllocatorV2; + const manager = yield* ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = yield* idAllocator.allocate.thread({ + fixtureName: "provider-session-manager-historical-native-delete-failure", + projectId: yield* idAllocator.allocate.project({ + fixtureName: "provider-session-manager-historical-native-delete-failure", + }), + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + + yield* manager + .detach({ + providerSessionId, + threadId, + detail: "Thread deleted.", + deleteProviderThread: true, + revokeMcpCredential: true, + providerInstanceId: modelSelection.instanceId, + providerSession: makeProviderSession({ providerSessionId, now }), + providerThreads: [makeProviderThread({ idAllocator, threadId, providerSessionId, now })], + }) + .pipe(Effect.flip); + + assert.equal((yield* Ref.get(state)).detachedDeleteCount, 1); + assert.isUndefined(McpProviderSession.readMcpProviderSession(threadId)); + }); + + yield* effect.pipe( + Effect.provide( + makeTestLayer({ + state, + idleTimeoutMs: 1_000, + capabilities: ExclusiveCapabilities, + failDetachedDeleteThread: true, + }), + ), + ); + }), +); + it.effect("ProviderSessionManagerV2 releases idle sessions without sweeping all sessions", () => 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 8eeacd7f685..483acaeaa5e 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1,6 +1,7 @@ import { ModelSelection, OrchestrationV2DomainEvent, + type OrchestrationV2ProviderThread, OrchestrationV2ProviderSession, OrchestrationV2RuntimeRequest, ProviderInstanceId, @@ -160,6 +161,18 @@ export interface ProviderSessionManagerV2Shape { * potential re-attach. */ readonly revokeMcpCredential?: boolean; + /** + * True only when the application thread is permanently deleted. Adapters + * with native thread deletion remove it before the runtime is detached. + */ + readonly deleteProviderThread?: boolean; + /** + * Persisted deletion targets used when the managed runtime has already + * stopped by the time the replay-safe detach effect executes. + */ + readonly providerInstanceId?: ProviderInstanceId; + readonly providerSession?: OrchestrationV2ProviderSession; + readonly providerThreads?: ReadonlyArray; }) => Effect.Effect; } @@ -1539,43 +1552,96 @@ export const layerWithOptions = ( Effect.gen(function* () { const key = sessionKey(input.providerSessionId); const currentEntry = (yield* Ref.get(sessions)).get(key); - if (currentEntry?.supportsMultipleProviderThreads === true) { - const projection = yield* Effect.option( - projectionStore.getThreadProjection(input.threadId), + const shouldLoadProviderThreads = + currentEntry !== undefined && + (currentEntry.supportsMultipleProviderThreads || input.deleteProviderThread === true); + const projection = shouldLoadProviderThreads + ? yield* Effect.option(projectionStore.getThreadProjection(input.threadId)) + : Option.none(); + let providerThreads: ReadonlyMap< + OrchestrationV2ProviderThread["id"], + OrchestrationV2ProviderThread + >; + if (input.providerThreads !== undefined) { + providerThreads = new Map( + input.providerThreads.map((thread) => [thread.id, thread] as const), ); - if (Option.isSome(projection)) { - const providerThreads = new Map( - projection.value.providerThreads - .filter((thread) => thread.providerSessionId === input.providerSessionId) - .map((thread) => [thread.id, thread] as const), - ); - const activeTurns = projection.value.providerTurns.filter( - (turn) => turn.status === "running" && providerThreads.has(turn.providerThreadId), + } else if (Option.isSome(projection)) { + providerThreads = new Map( + projection.value.providerThreads + .filter((thread) => thread.providerSessionId === input.providerSessionId) + .map((thread) => [thread.id, thread] as const), + ); + } else { + providerThreads = new Map(); + } + let deletionFailure: Exit.Exit | null = null; + if ( + input.deleteProviderThread === true && + currentEntry === undefined && + input.providerInstanceId !== undefined && + input.providerSession !== undefined + ) { + const adapter = yield* registry.get(input.providerInstanceId); + const deleteDetachedThread = adapter.deleteDetachedThread; + if (deleteDetachedThread !== undefined) { + const providerSession = input.providerSession; + const exits = yield* Effect.scoped( + Effect.forEach(providerThreads.values(), (providerThread) => + Effect.exit( + deleteDetachedThread({ + providerSession, + providerThread, + }), + ), + ), ); - yield* Effect.forEach( - activeTurns, - (turn) => - currentEntry.exposedRuntime - .interruptTurn({ - providerThread: providerThreads.get(turn.providerThreadId)!, - providerTurnId: turn.id, - }) - .pipe( - Effect.catchCause((cause) => - Effect.logWarning( - "orchestration-v2.driver-session.detach-interrupt-failed", - { - providerSessionId: input.providerSessionId, - threadId: input.threadId, - providerTurnId: turn.id, - cause, - }, - ), + deletionFailure = exits.find(Exit.isFailure) ?? null; + } + } + if ( + currentEntry !== undefined && + Option.isSome(projection) && + (currentEntry.supportsMultipleProviderThreads || input.deleteProviderThread === true) + ) { + const activeTurns = projection.value.providerTurns.filter( + (turn) => turn.status === "running" && providerThreads.has(turn.providerThreadId), + ); + yield* Effect.forEach( + activeTurns, + (turn) => + currentEntry.exposedRuntime + .interruptTurn({ + providerThread: providerThreads.get(turn.providerThreadId)!, + providerTurnId: turn.id, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "orchestration-v2.driver-session.detach-interrupt-failed", + { + providerSessionId: input.providerSessionId, + threadId: input.threadId, + providerTurnId: turn.id, + cause, + }, ), ), - { concurrency: 1, discard: true }, - ); - } + ), + { concurrency: 1, discard: true }, + ); + } + if ( + input.deleteProviderThread === true && + currentEntry?.exposedRuntime.deleteThread !== undefined + ) { + const exits = yield* Effect.forEach( + providerThreads.values(), + (providerThread) => + Effect.exit(currentEntry.exposedRuntime.deleteThread!(providerThread)), + { concurrency: 1 }, + ); + deletionFailure = exits.find(Exit.isFailure) ?? deletionFailure; } const detached = yield* Ref.modify(sessions, (current) => { const entry = current.get(key); @@ -1624,21 +1690,23 @@ export const layerWithOptions = ( if (input.revokeMcpCredential === true) { yield* clearMcpSession(input.threadId); } - if (Option.isNone(detached)) { - return; + if (Option.isSome(detached)) { + if ( + detached.value.attachedThreadIds.size === 0 && + !detached.value.supportsMultipleProviderThreads + ) { + yield* releaseEntry({ + providerSessionId: input.providerSessionId, + reason: "manual_shutdown", + ...(input.detail === undefined ? {} : { detail: input.detail }), + }); + } else { + yield* scheduleIdleRelease(input.providerSessionId); + } } - if ( - detached.value.attachedThreadIds.size === 0 && - !detached.value.supportsMultipleProviderThreads - ) { - yield* releaseEntry({ - providerSessionId: input.providerSessionId, - reason: "manual_shutdown", - ...(input.detail === undefined ? {} : { detail: input.detail }), - }); - return; + if (deletionFailure !== null && Exit.isFailure(deletionFailure)) { + return yield* Effect.failCause(deletionFailure.cause); } - yield* scheduleIdleRelease(input.providerSessionId); }).pipe( Effect.catchCause((cause) => Effect.fail( diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 0105fe37c9c..bf642c6e8e0 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -955,7 +955,19 @@ export const layer: Layer.Layer< // item's non-terminal event before the root terminal; an item // first seen after the terminal is not pinned. const backgroundItems = yield* Ref.get(activeBackgroundTurnItems); - return backgroundItems.size === 0; + if (backgroundItems.size > 0) { + return false; + } + const latestProviderThreadSnapshot = yield* Ref.get(latestProviderThread); + if (input.session.hasPendingBackgroundWorkForThread !== undefined) { + const hasPendingWork = yield* input.session + .hasPendingBackgroundWorkForThread(latestProviderThreadSnapshot) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + if (hasPendingWork) { + return false; + } + } + return true; }); const eventSubscription = input.session.subscribeEvents === undefined diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index e03d60c00d9..5b631c9141d 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -1,19 +1,29 @@ import { expect, it } from "@effect/vitest"; import { CommandId, + EventId, MessageId, NodeId, type OrchestrationV2Command, + type OrchestrationV2ThreadShell, type OrchestrationV2ThreadProjection, ProjectId, + ProviderDriverKind, ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, RunId, ThreadId, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { OrchestratorProjectionError, OrchestratorV2 } from "./Orchestrator.ts"; +import { + OrchestratorProjectionError, + OrchestratorV2, + type OrchestratorV2DispatchResult, +} from "./Orchestrator.ts"; import { existingThreadIdsForCommand, layer, @@ -318,3 +328,130 @@ it.effect("uses thread-not-found only after a projection loads outside the proje expect("cause" in error).toBe(false); }).pipe(Effect.provide(testLayer)); }); + +it.effect("interrupts an ordinary waiting root run when no native background target exists", () => { + const projectId = ProjectId.make("project:thread-management:waiting-root"); + const threadId = ThreadId.make("thread:thread-management:waiting-root"); + const waitingRunId = RunId.make("run:thread-management:waiting-root"); + const projection = { + thread: { + id: threadId, + projectId, + deletedAt: null, + }, + runs: [{ id: waitingRunId, ordinal: 1, status: "waiting" }], + } as unknown as OrchestrationV2ThreadProjection; + const testLayer = layer.pipe( + Layer.provide( + Layer.mock(OrchestratorV2)({ + getThreadProjection: () => Effect.succeed(projection), + getThreadShell: () => + Effect.succeed({ + hasInterruptibleProviderNativeBackgroundWork: false, + } as OrchestrationV2ThreadShell), + dispatch: () => Effect.succeed({ sequence: 1, storedEvents: [] }), + }), + ), + ); + + return Effect.gen(function* () { + const service = yield* ThreadManagementService; + const result = yield* service.interruptThread({ + projectId, + commandId: CommandId.make("command:thread-management:waiting-root-stop"), + threadId, + }); + + expect(result).toMatchObject({ + type: "interrupt_requested", + run: { id: waitingRunId, status: "waiting" }, + }); + }).pipe(Effect.provide(testLayer)); +}); + +it.effect("pins run-less background Stop to provider-native targets across a root-run race", () => { + const projectId = ProjectId.make("project:thread-management:provider-race"); + const threadId = ThreadId.make("thread:thread-management:provider-race"); + const waitingRunId = RunId.make("run:thread-management:provider-race"); + const providerThreadId = ProviderThreadId.make("provider-thread:thread-management:provider-race"); + const providerTurnId = ProviderTurnId.make("provider-turn:thread-management:provider-race"); + const commands: Array = []; + const projection = { + thread: { + id: threadId, + projectId, + deletedAt: null, + }, + runs: [{ id: waitingRunId, ordinal: 1, status: "waiting" }], + } as unknown as OrchestrationV2ThreadProjection; + const dispatchResult = { + sequence: 1, + storedEvents: [ + { + sequence: 1, + commandId: CommandId.make("command:thread-management:provider-race"), + event: { + id: EventId.make("event:thread-management:provider-race"), + type: "provider-turn.interrupt-requested", + threadId, + driver: ProviderDriverKind.make("opencode2"), + providerInstanceId: ProviderInstanceId.make("opencode2"), + occurredAt: DateTime.makeUnsafe("2026-08-01T12:00:00.000Z"), + payload: { + targetThreadId: ThreadId.make("thread:thread-management:provider-child"), + providerThreadId, + providerTurnId, + reason: null, + }, + }, + }, + ], + } satisfies OrchestratorV2DispatchResult; + const testLayer = layer.pipe( + Layer.provide( + Layer.mock(OrchestratorV2)({ + getThreadProjection: () => Effect.succeed(projection), + getThreadShell: () => + Effect.succeed({ + hasInterruptibleProviderNativeBackgroundWork: true, + } as OrchestrationV2ThreadShell), + dispatch: (command) => { + commands.push(command); + return Effect.succeed(dispatchResult); + }, + }), + ), + ); + + return Effect.gen(function* () { + const service = yield* ThreadManagementService; + const result = yield* service.interruptThread({ + projectId, + commandId: CommandId.make("command:thread-management:provider-race"), + threadId, + }); + + expect(result).toMatchObject({ + type: "provider_interrupt_requested", + targets: [ + { + threadId: ThreadId.make("thread:thread-management:provider-child"), + providerThreadId, + providerTurnId, + }, + ], + }); + expect(commands).toEqual([ + { + type: "run.interrupt", + commandId: "command:thread-management:provider-race", + threadId, + intent: "provider_native_only", + }, + ]); + expect( + (result as Extract).dispatch + .storedEvents[0]?.event.type, + ).toBe("provider-turn.interrupt-requested"); + }).pipe(Effect.provide(testLayer)); +}); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 90c7e40b7f5..e3f4a2cb0e0 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -13,6 +13,8 @@ import { type OrchestrationV2ThreadShell, type OrchestrationV2TurnItem, ProjectId, + type ProviderThreadId, + type ProviderTurnId, RunId, ThreadId, } from "@t3tools/contracts"; @@ -140,12 +142,23 @@ export interface ThreadManagementInterruptInput { readonly reason?: string; } +export interface ThreadManagementProviderInterruptTarget { + readonly threadId: ThreadId; + readonly providerThreadId: ProviderThreadId; + readonly providerTurnId: ProviderTurnId; +} + export type ThreadManagementInterruptResult = | { readonly type: "interrupt_requested"; readonly run: OrchestrationV2Run; readonly dispatch: OrchestratorV2DispatchResult; } + | { + readonly type: "provider_interrupt_requested"; + readonly targets: ReadonlyArray; + readonly dispatch: OrchestratorV2DispatchResult; + } | { readonly type: "no_active_run" } | { readonly type: "already_terminal"; @@ -615,17 +628,56 @@ const make = Effect.gen(function* () { }, } as const; } + // A provider-native child's own live turn gets the first Stop. Once the + // child turn is settled, the same path reaches its direct native + // descendants without crossing that child boundary. const interruptibleRun = latestActiveRun(target); - if (interruptibleRun === undefined) { + const shell = + input.runId === undefined && + (interruptibleRun === undefined || interruptibleRun.status === "waiting") + ? yield* orchestrator.getThreadShell(input.threadId) + : undefined; + const hasProviderNativeBackgroundWork = + shell?.hasInterruptibleProviderNativeBackgroundWork === true; + const rootInterruptibleRun = + input.runId === undefined && + interruptibleRun?.status === "waiting" && + hasProviderNativeBackgroundWork + ? undefined + : interruptibleRun; + if (rootInterruptibleRun === undefined) { if (input.runId === undefined) { - return { type: "no_active_run" } as const; + if (!hasProviderNativeBackgroundWork) { + return { type: "no_active_run" } as const; + } + const dispatch = yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: input.commandId, + threadId: input.threadId, + intent: "provider_native_only", + ...(input.reason === undefined ? {} : { reason: input.reason }), + }); + const targets = dispatch.storedEvents.flatMap((stored) => + stored.event.type === "provider-turn.interrupt-requested" + ? [ + { + threadId: stored.event.payload.targetThreadId, + providerThreadId: stored.event.payload.providerThreadId, + providerTurnId: stored.event.payload.providerTurnId, + }, + ] + : [], + ); + return targets.length === 0 + ? ({ type: "no_active_run" } as const) + : ({ type: "provider_interrupt_requested", targets, dispatch } as const); } return yield* new ThreadManagementThreadNotInterruptibleError({ threadId: input.threadId, runId: input.runId, }); } - if (input.runId !== undefined && interruptibleRun.id !== input.runId) { + if (input.runId !== undefined && rootInterruptibleRun.id !== input.runId) { return yield* new ThreadManagementThreadNotInterruptibleError({ threadId: input.threadId, runId: input.runId, @@ -635,10 +687,10 @@ const make = Effect.gen(function* () { type: "run.interrupt", commandId: input.commandId, threadId: input.threadId, - runId: interruptibleRun.id, + runId: rootInterruptibleRun.id, ...(input.reason === undefined ? {} : { reason: input.reason }), }); - return { type: "interrupt_requested", run: interruptibleRun, dispatch } as const; + return { type: "interrupt_requested", run: rootInterruptibleRun, dispatch } as const; }); return ThreadManagementService.of({ diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index 1ef37a2bfa0..4adc331667a 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -14,6 +14,10 @@ import { type CursorAdapterV2DriverEnv, } from "./Adapters/CursorAdapterV2.ts"; import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts"; +import { + OpenCode2AdapterV2Driver, + type OpenCode2AdapterV2DriverEnv, +} from "./Adapters/OpenCode2AdapterV2.ts"; import { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv, @@ -26,6 +30,7 @@ export type BuiltInProviderAdapterDriversV2Env = | CodexAdapterV2DriverEnv | CursorAdapterV2DriverEnv | GrokAdapterV2DriverEnv + | OpenCode2AdapterV2DriverEnv | OpenCodeAdapterV2DriverEnv; export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< @@ -35,6 +40,7 @@ export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< ClaudeAdapterV2Driver, CursorAdapterV2Driver, OpenCodeAdapterV2Driver, + OpenCode2AdapterV2Driver, GrokAdapterV2Driver, AcpRegistryAdapterV2Driver, ]; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 1f34589ec36..53adcda2b44 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1216,6 +1216,16 @@ it.layer(SharedApplicationDataPlaneTestLayer)("pending provider interruption", ( ); assert.deepEqual(interrupted.providerTurns, []); assert.isFalse(yield* effectWorker.runOnce); + + const sequenceBeforeIdleStop = yield* orchestrator.getThreadEventSequence(threadId); + const idleStop = yield* threadManagement.interruptThread({ + projectId, + commandId: CommandId.make("runtime-layer-pending-interrupt-idle-stop"), + threadId, + reason: "Repeated stop after completion", + }); + assert.equal(idleStop.type, "no_active_run"); + assert.equal(yield* orchestrator.getThreadEventSequence(threadId), sequenceBeforeIdleStop); }), ); }); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 38aa57b570b..a55b3335dcb 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -27,6 +27,8 @@ import { layer as projectionMaintenanceLayer } from "./ProjectionMaintenance.ts" import { layerFromProviderInstanceRegistry as providerAdapterRegistryLayerFromProviderInstances } from "./ProviderAdapterRegistry.ts"; import { layer as providerContinuationRequestsLayer } from "./ProviderContinuationRequests.ts"; import { workerLive as providerContinuationWorkerLive } from "./ProviderContinuationService.ts"; +import { layer as providerInteractionModeReflectionsLayer } from "./ProviderInteractionModeReflections.ts"; +import { workerLive as providerInteractionModeReflectionWorkerLive } from "./ProviderInteractionModeReflectionService.ts"; import { workerLive as threadTitleRegenerationWorkerLive } from "./ThreadTitleRegenerationService.ts"; import { layer as providerEventIngestorLayer } from "./ProviderEventIngestor.ts"; import { layer as providerSessionManagerLayer } from "./ProviderSessionManager.ts"; @@ -199,6 +201,7 @@ const orchestratorProvided = orchestratorLayer.pipe( // Same layer reference as the continuation worker and the adapter // infrastructure so layer memoization yields one shared request queue. providerContinuationRequestsLayer, + providerInteractionModeReflectionsLayer, providerEventIngestorProvided, runtimePolicyProvided, providerSessionManagerProvided, @@ -237,6 +240,10 @@ const providerContinuationWorkerProvided = providerContinuationWorkerLive.pipe( Layer.mergeAll(providerContinuationRequestsLayer, threadManagementProvided, idAllocatorLayer), ), ); +const providerInteractionModeReflectionWorkerProvided = + providerInteractionModeReflectionWorkerLive.pipe( + Layer.provide(Layer.merge(providerInteractionModeReflectionsLayer, threadManagementProvided)), + ); const threadTitleRegenerationWorkerProvided = threadTitleRegenerationWorkerLive.pipe( Layer.provide(Layer.merge(threadManagementProvided, ProjectServiceLayerLive)), ); @@ -260,4 +267,5 @@ export const OrchestrationV2ProductionLayerLive = Layer.mergeAll( threadLifecycleProvided, scheduledTaskProvided, providerContinuationWorkerProvided, + providerInteractionModeReflectionWorkerProvided, ); 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 09b756655dc..274bc7a6242 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts @@ -10,6 +10,7 @@ import { CursorOrchestratorReplayHarness } from "../Adapters/CursorAdapterV2.tes import { AcpRegistryOrchestratorReplayHarness } from "../Adapters/AcpRegistryAdapterV2.testkit.ts"; import { GrokOrchestratorReplayHarness } from "../Adapters/GrokAdapterV2.testkit.ts"; import { OpenCodeOrchestratorReplayHarness } from "../Adapters/OpenCodeAdapterV2.testkit.ts"; +import { OpenCode2OrchestratorReplayHarness } from "../Adapters/OpenCode2AdapterV2.testkit.ts"; import { layer as idAllocatorLayer } from "../IdAllocator.ts"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; @@ -153,6 +154,11 @@ function runFixtureProviderWithRegisteredHarness(input: { ...input, harness: OpenCodeOrchestratorReplayHarness, }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); + case "opencode2": + return runFixtureProvider({ + ...input, + harness: OpenCode2OrchestratorReplayHarness, + }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); default: return Effect.die( new Error(`No replay harness registered for provider ${input.driver.driver}.`), diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 8b1cc076678..fc19c08f7e2 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -3,6 +3,7 @@ import type { OrchestrationV2DomainEvent, OrchestrationV2RuntimeRequest, OrchestrationV2Run, + OrchestrationV2Subagent, OrchestrationV2ThreadShellSnapshot, OrchestrationV2StoredEvent, OrchestrationV2ThreadProjection, @@ -61,6 +62,12 @@ export type OrchestratorV2ScenarioStep = readonly runId: OrchestrationV2Run["id"]; readonly itemType: OrchestrationV2TurnItem["type"]; } + | { + readonly type: "await_subagent_status"; + readonly threadId: ThreadId; + readonly status: OrchestrationV2Subagent["status"]; + readonly subagentId?: OrchestrationV2Subagent["id"]; + } | { readonly type: "respond_to_next_runtime_request"; readonly threadId: ThreadId; @@ -354,6 +361,34 @@ export function runOrchestratorV2Scenario( return yield* waitForRunTurnItem(threadId, runId, itemType, attemptsRemaining - 1); }); + const waitForSubagentStatus = ( + threadId: ThreadId, + status: OrchestrationV2Subagent["status"], + subagentId?: OrchestrationV2Subagent["id"], + attemptsRemaining = SCENARIO_WAIT_ATTEMPTS, + ): Effect.Effect => + Effect.gen(function* () { + const projection = yield* orchestrator.getThreadProjection(threadId); + if ( + projection.subagents.some( + (subagent) => + subagent.threadId === threadId && + subagent.status === status && + (subagentId === undefined || subagent.id === subagentId), + ) + ) { + return; + } + if (attemptsRemaining <= 0) { + return yield* new OrchestratorV2ScenarioStepError({ + scenario: scenario.name, + step: `await_subagent_status:${threadId}:${subagentId ?? "any"}:${status}`, + }); + } + yield* yieldToRuntime; + return yield* waitForSubagentStatus(threadId, status, subagentId, attemptsRemaining - 1); + }); + for (const step of scenarioSteps(scenario)) { switch (step.type) { case "dispatch": { @@ -397,6 +432,9 @@ export function runOrchestratorV2Scenario( case "await_run_turn_item": yield* waitForRunTurnItem(step.threadId, step.runId, step.itemType); break; + case "await_subagent_status": + yield* waitForSubagentStatus(step.threadId, step.status, step.subagentId); + break; case "respond_to_next_runtime_request": { const request = yield* waitForPendingRuntimeRequest(step.threadId); const result = yield* orchestrator.dispatch({ diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index 510b85c4c46..9d5d321604a 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -16,6 +16,54 @@ import { messageSteeringInput } from "./message_steering/input.ts"; import { assertMultiTurnClaudeOutput } from "./multi_turn/claude_output.ts"; import { assertMultiTurnOutput } from "./multi_turn/codex_output.ts"; import { multiTurnInput } from "./multi_turn/input.ts"; +import { openCode2BackgroundStopInput } from "./opencode2_background_stop/input.ts"; +import { assertOpenCode2BackgroundStopOutput } from "./opencode2_background_stop/output.ts"; +import { openCode2BackgroundChildStopInput } from "./opencode2_background_child_stop/input.ts"; +import { assertOpenCode2BackgroundChildStopOutput } from "./opencode2_background_child_stop/output.ts"; +import { openCode2BackgroundChildStopRecoveryOrderInput } from "./opencode2_background_child_stop_recovery_order/input.ts"; +import { assertOpenCode2BackgroundChildStopRecoveryOrderOutput } from "./opencode2_background_child_stop_recovery_order/output.ts"; +import { openCode2BackgroundChildStopRecoveryRaceInput } from "./opencode2_background_child_stop_recovery_race/input.ts"; +import { assertOpenCode2BackgroundChildStopRecoveryRaceOutput } from "./opencode2_background_child_stop_recovery_race/output.ts"; +import { openCode2TwoBackgroundChildStopInput } from "./opencode2_two_background_child_stop/input.ts"; +import { assertOpenCode2TwoBackgroundChildStopOutput } from "./opencode2_two_background_child_stop/output.ts"; +import { openCode2TwoBackgroundChildReplayInput } from "./opencode2_two_background_child_replay/input.ts"; +import { assertOpenCode2TwoBackgroundChildReplayOutput } from "./opencode2_two_background_child_replay/output.ts"; +import { openCode2AmbiguousExecutionWakesInput } from "./opencode2_ambiguous_execution_wakes/input.ts"; +import { assertOpenCode2AmbiguousExecutionWakesOutput } from "./opencode2_ambiguous_execution_wakes/output.ts"; +import { openCode2RetiredSuppressWakeInput } from "./opencode2_retired_suppress_wake/input.ts"; +import { assertOpenCode2RetiredSuppressWakeOutput } from "./opencode2_retired_suppress_wake/output.ts"; +import { openCode2SharedExecutionReplayInput } from "./opencode2_shared_execution_replay/input.ts"; +import { assertOpenCode2SharedExecutionReplayOutput } from "./opencode2_shared_execution_replay/output.ts"; +import { openCode2SharedOrdinaryWakeReplayInput } from "./opencode2_shared_ordinary_wake_replay/input.ts"; +import { assertOpenCode2SharedOrdinaryWakeReplayOutput } from "./opencode2_shared_ordinary_wake_replay/output.ts"; +import { openCode2CompactionInput } from "./opencode2_compaction/input.ts"; +import { assertOpenCode2CompactionOutput } from "./opencode2_compaction/output.ts"; +import { openCode2PermissionExternalSubagentInput } from "./opencode2_permission_external_subagent/input.ts"; +import { assertOpenCode2PermissionExternalSubagentOutput } from "./opencode2_permission_external_subagent/output.ts"; +import { openCode2PermissionReplyFailureInput } from "./opencode2_permission_reply_failure/input.ts"; +import { assertOpenCode2PermissionReplyFailureOutput } from "./opencode2_permission_reply_failure/output.ts"; +import { openCode2PermissionReplyFailureSubagentInput } from "./opencode2_permission_reply_failure_subagent/input.ts"; +import { assertOpenCode2PermissionReplyFailureSubagentOutput } from "./opencode2_permission_reply_failure_subagent/output.ts"; +import { openCode2PermissionSessionInput } from "./opencode2_permission_session/input.ts"; +import { assertOpenCode2PermissionSessionOutput } from "./opencode2_permission_session/output.ts"; +import { openCode2QuestionLegacyInput } from "./opencode2_question_legacy/input.ts"; +import { assertOpenCode2QuestionLegacyOutput } from "./opencode2_question_legacy/output.ts"; +import { openCode2RetryInput } from "./opencode2_retry/input.ts"; +import { assertOpenCode2RetryOutput } from "./opencode2_retry/output.ts"; +import { openCode2ShellProjectionInput } from "./opencode2_shell_projection/input.ts"; +import { assertOpenCode2ShellProjectionOutput } from "./opencode2_shell_projection/output.ts"; +import { openCode2ShellTerminalsInput } from "./opencode2_shell_terminals/input.ts"; +import { assertOpenCode2ShellTerminalsOutput } from "./opencode2_shell_terminals/output.ts"; +import { openCode2SubagentBackgroundWakeInput } from "./opencode2_subagent_background_wake/input.ts"; +import { assertOpenCode2SubagentBackgroundWakeOutput } from "./opencode2_subagent_background_wake/output.ts"; +import { openCode2SubagentRateLimitInput } from "./opencode2_subagent_rate_limit/input.ts"; +import { assertOpenCode2SubagentRateLimitOutput } from "./opencode2_subagent_rate_limit/output.ts"; +import { openCode2SubagentQueuedTurnInput } from "./opencode2_subagent_queued_turn/input.ts"; +import { assertOpenCode2SubagentQueuedTurnOutput } from "./opencode2_subagent_queued_turn/output.ts"; +import { openCode2SubagentSupervisedInput } from "./opencode2_subagent_supervised/input.ts"; +import { assertOpenCode2SubagentSupervisedOutput } from "./opencode2_subagent_supervised/output.ts"; +import { openCode2ThreadDeleteInput } from "./opencode2_thread_delete/input.ts"; +import { assertOpenCode2ThreadDeleteOutput } from "./opencode2_thread_delete/output.ts"; import { openCodeSubagentInput } from "./opencode_subagent/input.ts"; import { assertOpenCodeSubagentOutput } from "./opencode_subagent/output.ts"; import { assertPlanQuestionsOutput } from "./plan_questions/codex_output.ts"; @@ -75,6 +123,7 @@ import { CODEX_MODEL_SELECTION, CURSOR_MODEL_SELECTION, GROK_MODEL_SELECTION, + OPENCODE2_MODEL_SELECTION, OPENCODE_MODEL_SELECTION, READ_ONLY_NEVER_POLICY, READ_ONLY_ON_REQUEST_POLICY, @@ -201,6 +250,12 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ modelSelection: OPENCODE_MODEL_SELECTION, assertOutput: assertSimpleOutput, }, + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL("./simple/opencode2_transcript.ndjson", import.meta.url), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertSimpleOutput, + }, ], }, { @@ -409,6 +464,365 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ }, ], }, + { + name: "opencode2_background_stop", + buildInput: openCode2BackgroundStopInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_background_stop/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2BackgroundStopOutput, + }, + ], + }, + { + name: "opencode2_background_child_stop", + buildInput: openCode2BackgroundChildStopInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_background_child_stop/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2BackgroundChildStopOutput, + }, + ], + }, + { + name: "opencode2_background_child_stop_recovery_order", + buildInput: openCode2BackgroundChildStopRecoveryOrderInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_background_child_stop_recovery_order/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2BackgroundChildStopRecoveryOrderOutput, + }, + ], + }, + { + name: "opencode2_background_child_stop_recovery_race", + buildInput: openCode2BackgroundChildStopRecoveryRaceInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_background_child_stop_recovery_race/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2BackgroundChildStopRecoveryRaceOutput, + }, + ], + }, + { + name: "opencode2_two_background_child_stop", + buildInput: openCode2TwoBackgroundChildStopInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_two_background_child_stop/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2TwoBackgroundChildStopOutput, + }, + ], + }, + { + name: "opencode2_two_background_child_replay", + buildInput: openCode2TwoBackgroundChildReplayInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_two_background_child_replay/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2TwoBackgroundChildReplayOutput, + }, + ], + }, + { + name: "opencode2_shared_ordinary_wake_replay", + buildInput: openCode2SharedOrdinaryWakeReplayInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_shared_ordinary_wake_replay/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2SharedOrdinaryWakeReplayOutput, + }, + ], + }, + { + name: "opencode2_shared_execution_replay", + buildInput: openCode2SharedExecutionReplayInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_shared_execution_replay/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2SharedExecutionReplayOutput, + }, + ], + }, + { + name: "opencode2_ambiguous_execution_wakes", + buildInput: openCode2AmbiguousExecutionWakesInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_ambiguous_execution_wakes/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2AmbiguousExecutionWakesOutput, + }, + ], + }, + { + name: "opencode2_retired_suppress_wake", + buildInput: openCode2RetiredSuppressWakeInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_retired_suppress_wake/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2RetiredSuppressWakeOutput, + }, + ], + }, + { + name: "opencode2_compaction", + buildInput: openCode2CompactionInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_compaction/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2CompactionOutput, + }, + ], + }, + { + name: "opencode2_permission_external_subagent", + buildInput: openCode2PermissionExternalSubagentInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_permission_external_subagent/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2PermissionExternalSubagentOutput, + }, + ], + }, + { + name: "opencode2_permission_reply_failure", + buildInput: openCode2PermissionReplyFailureInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_permission_reply_failure/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2PermissionReplyFailureOutput, + }, + ], + }, + { + name: "opencode2_permission_session", + buildInput: openCode2PermissionSessionInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_permission_session/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + runtimePolicyOverride: RESTRICTED_GRANULAR_POLICY, + assertOutput: assertOpenCode2PermissionSessionOutput, + }, + ], + }, + { + name: "opencode2_permission_reply_failure_subagent", + buildInput: openCode2PermissionReplyFailureSubagentInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_permission_reply_failure_subagent/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2PermissionReplyFailureSubagentOutput, + }, + ], + }, + { + name: "opencode2_question_legacy", + buildInput: openCode2QuestionLegacyInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_question_legacy/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2QuestionLegacyOutput, + }, + ], + }, + { + name: "opencode2_retry", + buildInput: openCode2RetryInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL("./opencode2_retry/opencode2_transcript.ndjson", import.meta.url), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2RetryOutput, + }, + ], + }, + { + name: "opencode2_shell_projection", + buildInput: openCode2ShellProjectionInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_shell_projection/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2ShellProjectionOutput, + }, + ], + }, + { + name: "opencode2_subagent_background_wake", + buildInput: openCode2SubagentBackgroundWakeInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_subagent_background_wake/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2SubagentBackgroundWakeOutput, + }, + ], + }, + { + name: "opencode2_subagent_queued_turn", + buildInput: openCode2SubagentQueuedTurnInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_subagent_queued_turn/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2SubagentQueuedTurnOutput, + }, + ], + }, + { + name: "opencode2_subagent_rate_limit", + buildInput: openCode2SubagentRateLimitInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_subagent_rate_limit/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2SubagentRateLimitOutput, + }, + ], + }, + { + name: "opencode2_subagent_supervised", + buildInput: openCode2SubagentSupervisedInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_subagent_supervised/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + runtimePolicyOverride: RESTRICTED_GRANULAR_POLICY, + assertOutput: assertOpenCode2SubagentSupervisedOutput, + }, + ], + }, + { + name: "opencode2_shell_terminals", + buildInput: openCode2ShellTerminalsInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_shell_terminals/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2ShellTerminalsOutput, + }, + ], + }, + { + name: "opencode2_thread_delete", + buildInput: openCode2ThreadDeleteInput, + providers: [ + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL( + "./opencode2_thread_delete/opencode2_transcript.ndjson", + import.meta.url, + ), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertOpenCode2ThreadDeleteOutput, + }, + ], + }, { name: "multi_turn", buildInput: multiTurnInput, @@ -443,6 +857,12 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ modelSelection: ACP_REGISTRY_MODEL_SELECTION, assertOutput: assertMultiTurnOutput, }, + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL("./multi_turn/opencode2_transcript.ndjson", import.meta.url), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertMultiTurnOutput, + }, ], }, { @@ -491,6 +911,12 @@ export const ORCHESTRATOR_REPLAY_FIXTURES = [ modelSelection: ACP_REGISTRY_MODEL_SELECTION, assertOutput: assertQueuedTurnOutput, }, + { + driver: ProviderDriverKind.make("opencode2"), + transcriptFile: new URL("./queued_turn/opencode2_transcript.ndjson", import.meta.url), + modelSelection: OPENCODE2_MODEL_SELECTION, + assertOutput: assertQueuedTurnOutput, + }, ], }, { diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/opencode2_transcript.ndjson new file mode 100644 index 00000000000..02ce06f9a60 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/opencode2_transcript.ndjson @@ -0,0 +1,32 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16383","scenario":"multi_turn","metadata":{"source":"derived-from-live-run","capturedAt":"2026-07-29","nativeSessionId":"ses_opencode2_multi_turn","model":"opencode/big-pickle","description":"Two OpenCode 2 turns with a late execution terminal from the first turn delivered after the second prompt is admitted. The stale terminal must not settle the new turn before its execution-start correlation barrier."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_multi_turn","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785297600000,"updated":1785297600000},"title":"T3 OpenCode 2 multi-turn replay","location":{"directory":"/private/tmp/t3-opencode2-replay"}}}} +{"type":"expect_outbound","label":"session.prompt.first","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_multi_turn","text":"Respond with exactly: first fixture turn complete"}}} +{"type":"emit_inbound","label":"session.prompt.first.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_first"}}} +{"type":"emit_inbound","label":"session.input.admitted.first","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_multi_turn","inputID":"input_opencode2_first","input":{"id":"input_opencode2_first","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started.first","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_multi_turn"}}}} +{"type":"emit_inbound","label":"session.text.started.first","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_multi_turn","assistantMessageID":"message_opencode2_first","ordinal":0}}}} +{"type":"emit_inbound","label":"session.text.delta.first","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_multi_turn","assistantMessageID":"message_opencode2_first","ordinal":0,"delta":"first fixture turn complete"}}}} +{"type":"emit_inbound","label":"session.text.ended.first","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_multi_turn","assistantMessageID":"message_opencode2_first","ordinal":0,"text":"first fixture turn complete"}}}} +{"type":"emit_inbound","label":"session.execution.succeeded.first","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_multi_turn"}}}} +{"type":"expect_outbound","label":"session.pending.list.first","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_multi_turn"}}} +{"type":"emit_inbound","label":"session.pending.list.first.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list.first","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-replay"}}}} +{"type":"emit_inbound","label":"shell.list.first.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"expect_outbound","label":"session.prompt.second","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_multi_turn","text":"Respond with exactly: second fixture turn complete"}}} +{"type":"emit_inbound","label":"session.prompt.second.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_second"}}} +{"type":"emit_inbound","label":"session.input.admitted.second","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_multi_turn","inputID":"input_opencode2_second","input":{"id":"input_opencode2_second","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.succeeded.late-first","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_multi_turn"}}}} +{"type":"emit_inbound","label":"session.execution.started.second","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_multi_turn"}}}} +{"type":"emit_inbound","label":"session.text.started.second","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_multi_turn","assistantMessageID":"message_opencode2_second","ordinal":0}}}} +{"type":"emit_inbound","label":"session.text.delta.second","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_multi_turn","assistantMessageID":"message_opencode2_second","ordinal":0,"delta":"second fixture turn complete"}}}} +{"type":"emit_inbound","label":"session.text.ended.second","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_multi_turn","assistantMessageID":"message_opencode2_second","ordinal":0,"text":"second fixture turn complete"}}}} +{"type":"emit_inbound","label":"session.execution.succeeded.second","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_multi_turn"}}}} +{"type":"expect_outbound","label":"session.pending.list.second","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_multi_turn"}}} +{"type":"emit_inbound","label":"session.pending.list.second.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list.second","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-replay"}}}} +{"type":"emit_inbound","label":"shell.list.second.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_ambiguous_execution_wakes/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_ambiguous_execution_wakes/input.ts new file mode 100644 index 00000000000..d36b1d337e7 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_ambiguous_execution_wakes/input.ts @@ -0,0 +1,13 @@ +import { OPENCODE2_SUBAGENT_BACKGROUND_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2AmbiguousExecutionWakesInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_SUBAGENT_BACKGROUND_PROMPT }, + { + type: "message", + text: "Recover after an ambiguous execution. Respond exactly RECOVERY_OK", + }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_ambiguous_execution_wakes/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_ambiguous_execution_wakes/opencode2_transcript.ndjson new file mode 100644 index 00000000000..a72553189d0 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_ambiguous_execution_wakes/opencode2_transcript.ndjson @@ -0,0 +1,40 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16540","scenario":"opencode2_ambiguous_execution_wakes","metadata":{"source":"focused-provider-native-replay-ownership","description":"Two suppressed synthetic wakes are pending without promotion when a new execution starts before its ordinary input is correlated. The unattributable output must be swallowed and the later recovery must remain clean."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_ambiguous_execution_wakes","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785394800000,"updated":1785394800000},"title":"T3 OpenCode 2 ambiguous execution wakes","location":{"directory":"/private/tmp/t3-opencode2-ambiguous-execution-wakes"}}}} +{"type":"expect_outbound","label":"session.prompt.root","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","text":"Start one background subagent with description background child fixture and prompt Respond exactly CHILD_BACKGROUND_OK. Then respond exactly PARENT_RELEASED without waiting for the child."}}} +{"type":"emit_inbound","label":"session.prompt.root.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_ambiguous_execution_wakes_root"}}} +{"type":"emit_inbound","label":"root.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","inputID":"input_opencode2_ambiguous_execution_wakes_root","input":{"type":"user","data":{"text":"Start one background subagent"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"root.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","inputID":"input_opencode2_ambiguous_execution_wakes_root"}}}} +{"type":"emit_inbound","label":"root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes"}}}} +{"type":"emit_inbound","label":"root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","assistantMessageID":"message_opencode2_ambiguous_execution_wakes_root","ordinal":0}}}} +{"type":"emit_inbound","label":"root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","assistantMessageID":"message_opencode2_ambiguous_execution_wakes_root","ordinal":0,"delta":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","assistantMessageID":"message_opencode2_ambiguous_execution_wakes_root","ordinal":0,"text":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes"}}}} +{"type":"expect_outbound","label":"root.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_ambiguous_execution_wakes"}}} +{"type":"emit_inbound","label":"root.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-ambiguous-execution-wakes"}}}} +{"type":"emit_inbound","label":"root.shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"emit_inbound","label":"suppressed.alpha.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","inputID":"input_opencode2_ambiguous_execution_wakes_alpha","input":{"type":"synthetic","data":{"text":"ALPHA_CANCELLED","description":"alpha cancelled child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"suppressed.bravo.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","inputID":"input_opencode2_ambiguous_execution_wakes_bravo","input":{"type":"synthetic","data":{"text":"BRAVO_INTERRUPTED","description":"bravo interrupted child fixture"},"delivery":"queue"}}}}} +{"type":"expect_outbound","label":"session.prompt.recovery","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","text":"Recover after an ambiguous execution. Respond exactly RECOVERY_OK"}}} +{"type":"emit_inbound","label":"session.prompt.recovery.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{}}} +{"type":"emit_inbound","label":"ambiguous.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes"}}}} +{"type":"emit_inbound","label":"recovery.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","inputID":"input_opencode2_ambiguous_execution_wakes_recovery","input":{"type":"user","data":{"text":"Recover after an ambiguous execution. Respond exactly RECOVERY_OK"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"ambiguous.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","assistantMessageID":"message_opencode2_ambiguous_execution_wakes_must_not_appear","ordinal":0}}}} +{"type":"emit_inbound","label":"ambiguous.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","assistantMessageID":"message_opencode2_ambiguous_execution_wakes_must_not_appear","ordinal":0,"delta":"AMBIGUOUS_OUTPUT_MUST_NOT_APPEAR"}}}} +{"type":"emit_inbound","label":"ambiguous.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","assistantMessageID":"message_opencode2_ambiguous_execution_wakes_must_not_appear","ordinal":0,"text":"AMBIGUOUS_OUTPUT_MUST_NOT_APPEAR"}}}} +{"type":"emit_inbound","label":"ambiguous.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes"}}}} +{"type":"emit_inbound","label":"recovery.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","inputID":"input_opencode2_ambiguous_execution_wakes_recovery"}}}} +{"type":"emit_inbound","label":"recovery.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes"}}}} +{"type":"emit_inbound","label":"recovery.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","assistantMessageID":"message_opencode2_ambiguous_execution_wakes_recovery","ordinal":0}}}} +{"type":"emit_inbound","label":"recovery.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","assistantMessageID":"message_opencode2_ambiguous_execution_wakes_recovery","ordinal":0,"delta":"RECOVERY_OK"}}}} +{"type":"emit_inbound","label":"recovery.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes","assistantMessageID":"message_opencode2_ambiguous_execution_wakes_recovery","ordinal":0,"text":"RECOVERY_OK"}}}} +{"type":"emit_inbound","label":"recovery.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_ambiguous_execution_wakes"}}}} +{"type":"expect_outbound","label":"recovery.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_ambiguous_execution_wakes"}}} +{"type":"emit_inbound","label":"recovery.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"recovery.shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-ambiguous-execution-wakes"}}}} +{"type":"emit_inbound","label":"recovery.shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_ambiguous_execution_wakes/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_ambiguous_execution_wakes/output.ts new file mode 100644 index 00000000000..bd8441568f8 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_ambiguous_execution_wakes/output.ts @@ -0,0 +1,27 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2AmbiguousExecutionWakesOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + const projection = projectionFor(result, transcript.scenario); + assertBaseProjection({ + result, + transcript, + runCount: 2, + runStatuses: ["completed", "completed"], + }); + assertSemanticProjectionIntegrity(projection); + assertAssistantTextIncludes(projection, "PARENT_RELEASED"); + assertAssistantTextIncludes(projection, "RECOVERY_OK"); + assert.notInclude(JSON.stringify(projection), "AMBIGUOUS_OUTPUT_MUST_NOT_APPEAR"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop/input.ts new file mode 100644 index 00000000000..7705bd3a608 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop/input.ts @@ -0,0 +1,14 @@ +import { OPENCODE2_SUBAGENT_BACKGROUND_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2BackgroundChildStopInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_SUBAGENT_BACKGROUND_PROMPT }, + { + type: "interrupt_provider_native", + subagentNativeItemId: "tool:call_opencode2_background_child_stop", + }, + { type: "message", text: "Recover after cancellation. Respond exactly RECOVERY_OK" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop/opencode2_transcript.ndjson new file mode 100644 index 00000000000..dceb82d4167 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop/opencode2_transcript.ndjson @@ -0,0 +1,62 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16540","scenario":"opencode2_background_child_stop","metadata":{"source":"focused-provider-native-stop","description":"Interrupt a directly projected OpenCode 2 background child after the root run settles. The child shell must be removed on the child native session, interrupted partial output must be retained, the cancelled synthetic parent span must be isolated, and recovery input is admitted while the cancelled native execution is still draining."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_background_child_stop","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785394800000,"updated":1785394800000},"title":"T3 OpenCode 2 background child stop replay","location":{"directory":"/private/tmp/t3-opencode2-background-child-stop"}}}} +{"type":"expect_outbound","label":"session.prompt.root","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_background_child_stop","text":"Start one background subagent with description background child fixture and prompt Respond exactly CHILD_BACKGROUND_OK. Then respond exactly PARENT_RELEASED without waiting for the child."}}} +{"type":"emit_inbound","label":"session.prompt.root.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_background_child_stop_root"}}} +{"type":"emit_inbound","label":"root.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop","inputID":"input_opencode2_background_child_stop_root","input":{"type":"user","data":{"text":"Start one background subagent"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"root.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop","inputID":"input_opencode2_background_child_stop_root"}}}} +{"type":"emit_inbound","label":"root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_background_child_stop"}}}} +{"type":"emit_inbound","label":"subagent.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_background_child_stop","assistantMessageID":"message_opencode2_background_child_stop_root","ordinal":0,"callID":"call_opencode2_background_child_stop","name":"subagent"}}}} +{"type":"emit_inbound","label":"subagent.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_background_child_stop","assistantMessageID":"message_opencode2_background_child_stop_root","ordinal":0,"callID":"call_opencode2_background_child_stop","input":{"agent":"explore","background":true,"description":"background child fixture","prompt":"Respond exactly CHILD_BACKGROUND_OK"}}}}} +{"type":"emit_inbound","label":"subagent.launch.success","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_background_child_stop","assistantMessageID":"message_opencode2_background_child_stop_root","ordinal":0,"callID":"call_opencode2_background_child_stop","content":[{"type":"text","text":"Background subagent launched"}],"structured":{"sessionID":"ses_opencode2_background_child_stop_child"}}}}} +{"type":"emit_inbound","label":"child.session.created","frame":{"type":"sdk.event","event":{"id":"event_opencode2_background_child_stop_child","created":1785394800100,"type":"session.created","data":{"sessionID":"ses_opencode2_background_child_stop_child","info":{"id":"ses_opencode2_background_child_stop_child","slug":"background-child-stop","projectID":"global","directory":"/private/tmp/t3-opencode2-background-child-stop","parentID":"ses_opencode2_background_child_stop","title":"background child fixture","agent":"explore","model":{"id":"big-pickle","providerID":"opencode"},"version":"0.0.0-next-16540","time":{"created":1785394800100,"updated":1785394800100}}}}}} +{"type":"emit_inbound","label":"child.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop_child","inputID":"input_opencode2_background_child_stop_child","input":{"type":"user","data":{"text":"Respond exactly CHILD_BACKGROUND_OK"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"child.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop_child","inputID":"input_opencode2_background_child_stop_child"}}}} +{"type":"emit_inbound","label":"child.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_background_child_stop_child"}}}} +{"type":"emit_inbound","label":"child.tool.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_background_child_stop_child","assistantMessageID":"message_opencode2_background_child_stop_child","ordinal":0,"callID":"call_opencode2_background_child_stop_shell","name":"bash"}}}} +{"type":"emit_inbound","label":"child.tool.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_background_child_stop_child","assistantMessageID":"message_opencode2_background_child_stop_child","ordinal":0,"callID":"call_opencode2_background_child_stop_shell","input":{"command":"sleep 30 && echo child partial"}}}}} +{"type":"emit_inbound","label":"child.shell.created","frame":{"type":"sdk.event","event":{"type":"shell.created","data":{"info":{"id":"shell_opencode2_background_child_stop","status":"running","command":"sleep 30 && echo child partial","cwd":"/private/tmp/t3-opencode2-background-child-stop","shell":"/bin/bash","file":"/private/tmp/t3-opencode2-background-child-stop/shell.log","pid":4246,"metadata":{"sessionID":"ses_opencode2_background_child_stop_child"},"time":{"started":1785394800200}}}}}} +{"type":"emit_inbound","label":"child.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_background_child_stop_child","assistantMessageID":"message_opencode2_background_child_stop_partial","ordinal":1}}}} +{"type":"emit_inbound","label":"child.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_background_child_stop_child","assistantMessageID":"message_opencode2_background_child_stop_partial","ordinal":1,"delta":"CHILD_PARTIAL"}}}} +{"type":"emit_inbound","label":"child.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_background_child_stop_child","assistantMessageID":"message_opencode2_background_child_stop_partial","ordinal":1,"text":"CHILD_PARTIAL"}}}} +{"type":"emit_inbound","label":"root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_background_child_stop","assistantMessageID":"message_opencode2_background_child_stop_root","ordinal":1}}}} +{"type":"emit_inbound","label":"root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_background_child_stop","assistantMessageID":"message_opencode2_background_child_stop_root","ordinal":1,"delta":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_background_child_stop","assistantMessageID":"message_opencode2_background_child_stop_root","ordinal":1,"text":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_background_child_stop"}}}} +{"type":"expect_outbound","label":"child.session.interrupt","frame":{"type":"session.interrupt","input":{"sessionID":"ses_opencode2_background_child_stop_child"}}} +{"type":"emit_inbound","label":"child.session.interrupt.response","frame":{"type":"sdk.response","operation":"session.interrupt","data":true}} +{"type":"expect_outbound","label":"child.shell.remove","frame":{"type":"shell.remove","input":{"id":"shell_opencode2_background_child_stop","location":{"directory":"/private/tmp/t3-opencode2-background-child-stop"}}}} +{"type":"emit_inbound","label":"child.shell.remove.response","frame":{"type":"sdk.response","operation":"shell.remove","data":true}} +{"type":"emit_inbound","label":"child.shell.deleted","frame":{"type":"sdk.event","event":{"type":"shell.deleted","data":{"id":"shell_opencode2_background_child_stop"}}}} +{"type":"emit_inbound","label":"child.tool.failed","frame":{"type":"sdk.event","event":{"type":"session.tool.failed","data":{"sessionID":"ses_opencode2_background_child_stop_child","assistantMessageID":"message_opencode2_background_child_stop_child","callID":"call_opencode2_background_child_stop_shell","error":{"type":"ToolExecutionError","message":"Tool execution interrupted"},"executed":true}}}} +{"type":"emit_inbound","label":"cancelled.parent.wake","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop","inputID":"input_opencode2_background_child_stop_cancelled","input":{"type":"synthetic","data":{"text":"CHILD_PARTIAL","description":"background child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"cancelled.parent.wake.second","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop","inputID":"input_opencode2_background_child_stop_cancelled_second","input":{"type":"synthetic","data":{"text":"CHILD_PARTIAL_SECOND","description":"background child fixture second"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"cancelled.input.promoted.first","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop","inputID":"input_opencode2_background_child_stop_cancelled"}}}} +{"type":"emit_inbound","label":"cancelled.input.promoted.second","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop","inputID":"input_opencode2_background_child_stop_cancelled_second"}}}} +{"type":"emit_inbound","label":"cancelled.root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_background_child_stop"}}}} +{"type":"emit_inbound","label":"child.execution.interrupted","frame":{"type":"sdk.event","event":{"type":"session.execution.interrupted","data":{"sessionID":"ses_opencode2_background_child_stop_child","reason":"user"}}}} +{"type":"expect_outbound","label":"root.pending.list.after.stop","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_background_child_stop"}}} +{"type":"emit_inbound","label":"root.pending.list.after.stop.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list.after.stop","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-background-child-stop"}}}} +{"type":"emit_inbound","label":"root.shell.list.after.stop.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"expect_outbound","label":"session.prompt.recovery","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_background_child_stop","text":"Recover after cancellation. Respond exactly RECOVERY_OK"}}} +{"type":"emit_inbound","label":"session.prompt.recovery.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_background_child_stop_recovery"}}} +{"type":"emit_inbound","label":"recovery.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop","inputID":"input_opencode2_background_child_stop_recovery","input":{"type":"user","data":{"text":"Recover after cancellation. Respond exactly RECOVERY_OK"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"cancelled.root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_background_child_stop","assistantMessageID":"message_opencode2_background_child_stop_cancelled","ordinal":0}}}} +{"type":"emit_inbound","label":"cancelled.root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_background_child_stop","assistantMessageID":"message_opencode2_background_child_stop_cancelled","ordinal":0,"delta":"CANCELLED_ROOT_OUTPUT_MUST_NOT_APPEAR"}}}} +{"type":"emit_inbound","label":"cancelled.root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_background_child_stop","assistantMessageID":"message_opencode2_background_child_stop_cancelled","ordinal":0,"text":"CANCELLED_ROOT_OUTPUT_MUST_NOT_APPEAR"}}}} +{"type":"emit_inbound","label":"cancelled.root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_background_child_stop"}}}} +{"type":"emit_inbound","label":"recovery.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop","inputID":"input_opencode2_background_child_stop_recovery"}}}} +{"type":"emit_inbound","label":"recovery.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_background_child_stop"}}}} +{"type":"emit_inbound","label":"recovery.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_background_child_stop","assistantMessageID":"message_opencode2_background_child_stop_recovery","ordinal":0}}}} +{"type":"emit_inbound","label":"recovery.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_background_child_stop","assistantMessageID":"message_opencode2_background_child_stop_recovery","ordinal":0,"delta":"RECOVERY_OK"}}}} +{"type":"emit_inbound","label":"recovery.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_background_child_stop","assistantMessageID":"message_opencode2_background_child_stop_recovery","ordinal":0,"text":"RECOVERY_OK"}}}} +{"type":"emit_inbound","label":"recovery.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_background_child_stop"}}}} +{"type":"expect_outbound","label":"root.pending.list.after.recovery","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_background_child_stop"}}} +{"type":"emit_inbound","label":"root.pending.list.after.recovery.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list.after.recovery","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-background-child-stop"}}}} +{"type":"emit_inbound","label":"root.shell.list.after.recovery.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop/output.ts new file mode 100644 index 00000000000..743f1b3ce2b --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop/output.ts @@ -0,0 +1,77 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2BackgroundChildStopOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + const cancellationStarted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "cancelled.root.execution.started", + ); + const recoveryAdmitted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "recovery.input.admitted", + ); + const cancellationEnded = transcript.entries.findIndex( + (entry) => + entry.type === "emit_inbound" && entry.label === "cancelled.root.execution.succeeded", + ); + const recoveryStarted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "recovery.execution.started", + ); + assert.isAtLeast(cancellationStarted, 0); + assert.isAtLeast(recoveryAdmitted, 0); + assert.isAtLeast(cancellationEnded, 0); + assert.isAtLeast(recoveryStarted, 0); + assert.isAbove(recoveryAdmitted, cancellationStarted); + assert.isAbove(cancellationEnded, recoveryAdmitted); + assert.isAbove(recoveryStarted, cancellationEnded); + + const sessionInterruptIndex = transcript.entries.findIndex( + (entry) => entry.type === "expect_outbound" && entry.label === "child.session.interrupt", + ); + const shellRemoveIndex = transcript.entries.findIndex( + (entry) => entry.type === "expect_outbound" && entry.label === "child.shell.remove", + ); + assert.isAtLeast(sessionInterruptIndex, 0); + assert.isAtLeast(shellRemoveIndex, 0); + assert.isAbove(shellRemoveIndex, sessionInterruptIndex); + + const projection = projectionFor(result, transcript.scenario); + assertBaseProjection({ + result, + transcript, + runCount: 2, + runStatuses: ["completed", "completed"], + }); + assertSemanticProjectionIntegrity(projection); + assert.equal(projection.runs.length, 2, "cancellation must not create an ordinary success wake"); + assertAssistantTextIncludes(projection, "RECOVERY_OK"); + assert.notInclude( + projection.messages.map((message) => message.text).join("\n"), + "CANCELLED_ROOT_OUTPUT_MUST_NOT_APPEAR", + ); + assert.notInclude(JSON.stringify(projection), "CANCELLED_ROOT_OUTPUT_MUST_NOT_APPEAR"); + assert.notInclude(JSON.stringify(projection), "CHILD_PARTIAL_SECOND"); + const subagentItem = projection.turnItems.find((item) => item.type === "subagent"); + assert.strictEqual(subagentItem?.type, "subagent"); + if (subagentItem?.type !== "subagent") { + throw new Error("OpenCode 2 background child stop item is missing"); + } + assert.equal(subagentItem.status, "interrupted"); + assert.include(subagentItem.result ?? "", "CHILD_PARTIAL"); + assert.isNotNull(subagentItem.childThreadId); + + const child = result.projections.get(subagentItem.childThreadId!); + assert.isDefined(child); + assertAssistantTextIncludes(child!, "CHILD_PARTIAL"); + assert.equal(child!.providerTurns.at(-1)?.status, "interrupted"); + assert.equal(child!.providerThreads.at(-1)?.status, "idle"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_order/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_order/input.ts new file mode 100644 index 00000000000..b6b2f72ca3e --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_order/input.ts @@ -0,0 +1,14 @@ +import { OPENCODE2_SUBAGENT_BACKGROUND_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2BackgroundChildStopRecoveryOrderInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_SUBAGENT_BACKGROUND_PROMPT }, + { + type: "interrupt_provider_native", + subagentNativeItemId: "tool:call_opencode2_background_child_stop_recovery_order", + }, + { type: "message", text: "Recover after cancellation. Respond exactly RECOVERY_OK" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_order/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_order/opencode2_transcript.ndjson new file mode 100644 index 00000000000..d3645463cba --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_order/opencode2_transcript.ndjson @@ -0,0 +1,57 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16540","scenario":"opencode2_background_child_stop_recovery_order","metadata":{"source":"focused-provider-native-stop","description":"A user recovery input is admitted before cancelled synthetic inputs. Their promoted ids own separate cancellation and user executions, with the cancellation execution suppressed."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_background_child_stop_recovery_order","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785394800000,"updated":1785394800000},"title":"T3 OpenCode 2 recovery ordering","location":{"directory":"/private/tmp/t3-opencode2-background-child-stop"}}}} +{"type":"expect_outbound","label":"session.prompt.root","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","text":"Start one background subagent with description background child fixture and prompt Respond exactly CHILD_BACKGROUND_OK. Then respond exactly PARENT_RELEASED without waiting for the child."}}} +{"type":"emit_inbound","label":"session.prompt.root.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_background_child_stop_recovery_order_root"}}} +{"type":"emit_inbound","label":"root.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","inputID":"input_opencode2_background_child_stop_recovery_order_root","input":{"type":"user","data":{"text":"Start one background subagent"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"root.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","inputID":"input_opencode2_background_child_stop_recovery_order_root"}}}} +{"type":"emit_inbound","label":"root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order"}}}} +{"type":"emit_inbound","label":"subagent.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","assistantMessageID":"message_opencode2_background_child_stop_recovery_order_root","ordinal":0,"callID":"call_opencode2_background_child_stop_recovery_order","name":"subagent"}}}} +{"type":"emit_inbound","label":"subagent.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","assistantMessageID":"message_opencode2_background_child_stop_recovery_order_root","ordinal":0,"callID":"call_opencode2_background_child_stop_recovery_order","input":{"agent":"explore","background":true,"description":"background child fixture","prompt":"Respond exactly CHILD_BACKGROUND_OK"}}}}} +{"type":"emit_inbound","label":"subagent.launch.success","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","assistantMessageID":"message_opencode2_background_child_stop_recovery_order_root","ordinal":0,"callID":"call_opencode2_background_child_stop_recovery_order","content":[{"type":"text","text":"Background subagent launched"}],"structured":{"sessionID":"ses_opencode2_background_child_stop_recovery_order_child"}}}}} +{"type":"emit_inbound","label":"child.session.created","frame":{"type":"sdk.event","event":{"id":"event_opencode2_background_child_stop_recovery_order_child","created":1785394800100,"type":"session.created","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order_child","info":{"id":"ses_opencode2_background_child_stop_recovery_order_child","slug":"background-child-stop","projectID":"global","directory":"/private/tmp/t3-opencode2-background-child-stop","parentID":"ses_opencode2_background_child_stop_recovery_order","title":"background child fixture","agent":"explore","model":{"id":"big-pickle","providerID":"opencode"},"version":"0.0.0-next-16540","time":{"created":1785394800100,"updated":1785394800100}}}}}} +{"type":"emit_inbound","label":"child.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order_child","inputID":"input_opencode2_background_child_stop_recovery_order_child","input":{"type":"user","data":{"text":"Respond exactly CHILD_BACKGROUND_OK"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"child.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order_child","inputID":"input_opencode2_background_child_stop_recovery_order_child"}}}} +{"type":"emit_inbound","label":"child.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order_child"}}}} +{"type":"emit_inbound","label":"child.shell.created","frame":{"type":"sdk.event","event":{"type":"shell.created","data":{"info":{"id":"shell_opencode2_background_child_stop_recovery_order","status":"running","command":"sleep 30 && echo child partial","cwd":"/private/tmp/t3-opencode2-background-child-stop","shell":"/bin/bash","file":"/private/tmp/t3-opencode2-background-child-stop/shell.log","pid":4246,"metadata":{"sessionID":"ses_opencode2_background_child_stop_recovery_order_child"},"time":{"started":1785394800200}}}}}} +{"type":"emit_inbound","label":"root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","assistantMessageID":"message_opencode2_background_child_stop_recovery_order_root","ordinal":1}}}} +{"type":"emit_inbound","label":"root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","assistantMessageID":"message_opencode2_background_child_stop_recovery_order_root","ordinal":1,"delta":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","assistantMessageID":"message_opencode2_background_child_stop_recovery_order_root","ordinal":1,"text":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order"}}}} +{"type":"expect_outbound","label":"child.session.interrupt","frame":{"type":"session.interrupt","input":{"sessionID":"ses_opencode2_background_child_stop_recovery_order_child"}}} +{"type":"emit_inbound","label":"child.session.interrupt.response","frame":{"type":"sdk.response","operation":"session.interrupt","data":true}} +{"type":"expect_outbound","label":"child.shell.remove","frame":{"type":"shell.remove","input":{"id":"shell_opencode2_background_child_stop_recovery_order","location":{"directory":"/private/tmp/t3-opencode2-background-child-stop"}}}} +{"type":"emit_inbound","label":"child.shell.remove.response","frame":{"type":"sdk.response","operation":"shell.remove","data":true}} +{"type":"emit_inbound","label":"child.shell.deleted","frame":{"type":"sdk.event","event":{"type":"shell.deleted","data":{"id":"shell_opencode2_background_child_stop_recovery_order"}}}} +{"type":"emit_inbound","label":"child.tool.failed","frame":{"type":"sdk.event","event":{"type":"session.tool.failed","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order_child","assistantMessageID":"message_opencode2_background_child_stop_recovery_order_child","callID":"call_opencode2_background_child_stop_recovery_order_shell","error":{"type":"ToolExecutionError","message":"Tool execution interrupted"},"executed":true}}}} +{"type":"emit_inbound","label":"child.execution.interrupted","frame":{"type":"sdk.event","event":{"type":"session.execution.interrupted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order_child","reason":"user"}}}} +{"type":"expect_outbound","label":"root.pending.list.after.stop","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_background_child_stop_recovery_order"}}} +{"type":"emit_inbound","label":"root.pending.list.after.stop.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list.after.stop","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-background-child-stop"}}}} +{"type":"emit_inbound","label":"root.shell.list.after.stop.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"expect_outbound","label":"session.prompt.recovery","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","text":"Recover after cancellation. Respond exactly RECOVERY_OK"}}} +{"type":"emit_inbound","label":"session.prompt.recovery.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_background_child_stop_recovery_order_recovery"}}} +{"type":"emit_inbound","label":"recovery.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","inputID":"input_opencode2_background_child_stop_recovery_order_recovery","input":{"type":"user","data":{"text":"Recover after cancellation. Respond exactly RECOVERY_OK"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"cancelled.parent.wake","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","inputID":"input_opencode2_background_child_stop_recovery_order_cancelled","input":{"type":"synthetic","data":{"text":"CANCELLED_ROOT_OUTPUT_MUST_NOT_APPEAR","description":"background child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"cancelled.parent.wake.second","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","inputID":"input_opencode2_background_child_stop_recovery_order_cancelled_second","input":{"type":"synthetic","data":{"text":"CANCELLED_ROOT_OUTPUT_SECOND_MUST_NOT_APPEAR","description":"background child fixture second"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"cancelled.input.promoted.first","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","inputID":"input_opencode2_background_child_stop_recovery_order_cancelled"}}}} +{"type":"emit_inbound","label":"cancelled.input.promoted.second","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","inputID":"input_opencode2_background_child_stop_recovery_order_cancelled_second"}}}} +{"type":"emit_inbound","label":"cancelled.root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order"}}}} +{"type":"emit_inbound","label":"cancelled.root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","assistantMessageID":"message_opencode2_background_child_stop_recovery_order_cancelled","ordinal":0}}}} +{"type":"emit_inbound","label":"cancelled.root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","assistantMessageID":"message_opencode2_background_child_stop_recovery_order_cancelled","ordinal":0,"delta":"CANCELLED_ROOT_OUTPUT_MUST_NOT_APPEAR"}}}} +{"type":"emit_inbound","label":"cancelled.root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","assistantMessageID":"message_opencode2_background_child_stop_recovery_order_cancelled","ordinal":0,"text":"CANCELLED_ROOT_OUTPUT_MUST_NOT_APPEAR"}}}} +{"type":"emit_inbound","label":"cancelled.root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order"}}}} +{"type":"emit_inbound","label":"recovery.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","inputID":"input_opencode2_background_child_stop_recovery_order_recovery"}}}} +{"type":"emit_inbound","label":"recovery.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order"}}}} +{"type":"emit_inbound","label":"recovery.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","assistantMessageID":"message_opencode2_background_child_stop_recovery_order_recovery","ordinal":0}}}} +{"type":"emit_inbound","label":"recovery.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","assistantMessageID":"message_opencode2_background_child_stop_recovery_order_recovery","ordinal":0,"delta":"RECOVERY_OK"}}}} +{"type":"emit_inbound","label":"recovery.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order","assistantMessageID":"message_opencode2_background_child_stop_recovery_order_recovery","ordinal":0,"text":"RECOVERY_OK"}}}} +{"type":"emit_inbound","label":"recovery.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_order"}}}} +{"type":"expect_outbound","label":"root.pending.list.after.recovery","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_background_child_stop_recovery_order"}}} +{"type":"emit_inbound","label":"root.pending.list.after.recovery.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list.after.recovery","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-background-child-stop"}}}} +{"type":"emit_inbound","label":"root.shell.list.after.recovery.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_order/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_order/output.ts new file mode 100644 index 00000000000..a5e033ae7cf --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_order/output.ts @@ -0,0 +1,75 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2BackgroundChildStopRecoveryOrderOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + const recoveryAdmitted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "recovery.input.admitted", + ); + const cancelledAdmitted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "cancelled.parent.wake", + ); + const cancelledPromoted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "cancelled.input.promoted.first", + ); + const cancelledStarted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "cancelled.root.execution.started", + ); + const cancelledEnded = transcript.entries.findIndex( + (entry) => + entry.type === "emit_inbound" && entry.label === "cancelled.root.execution.succeeded", + ); + const recoveryPromoted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "recovery.input.promoted", + ); + const recoveryStarted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "recovery.execution.started", + ); + assert.isAtLeast(recoveryAdmitted, 0); + assert.isAtLeast(cancelledAdmitted, 0); + assert.isAtLeast(cancelledPromoted, 0); + assert.isAtLeast(cancelledStarted, 0); + assert.isAtLeast(cancelledEnded, 0); + assert.isAtLeast(recoveryPromoted, 0); + assert.isAtLeast(recoveryStarted, 0); + assert.isAbove(cancelledAdmitted, recoveryAdmitted); + assert.isAbove(cancelledPromoted, cancelledAdmitted); + assert.isAbove(cancelledStarted, cancelledPromoted); + assert.isAbove(cancelledEnded, cancelledStarted); + assert.isAbove(recoveryPromoted, cancelledEnded); + assert.isAbove(recoveryStarted, recoveryPromoted); + + const projection = projectionFor(result, transcript.scenario); + assertBaseProjection({ + result, + transcript, + runCount: 2, + runStatuses: ["completed", "completed"], + }); + assertSemanticProjectionIntegrity(projection); + assertAssistantTextIncludes(projection, "PARENT_RELEASED"); + assertAssistantTextIncludes(projection, "RECOVERY_OK"); + assert.notInclude(JSON.stringify(projection), "CANCELLED_ROOT_OUTPUT_MUST_NOT_APPEAR"); + + const subagentItem = projection.turnItems.find((item) => item.type === "subagent"); + assert.strictEqual(subagentItem?.type, "subagent"); + if (subagentItem?.type !== "subagent") { + throw new Error("OpenCode 2 recovery ordering fixture is missing its child item"); + } + assert.equal(subagentItem.status, "interrupted"); + assert.isNotNull(subagentItem.childThreadId); + const child = result.projections.get(subagentItem.childThreadId!); + assert.isDefined(child); + assert.equal(child!.providerTurns.at(-1)?.status, "interrupted"); + assert.notInclude(JSON.stringify(child), "CANCELLED_ROOT_OUTPUT_MUST_NOT_APPEAR"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_race/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_race/input.ts new file mode 100644 index 00000000000..a5eebb826bd --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_race/input.ts @@ -0,0 +1,14 @@ +import { OPENCODE2_SUBAGENT_BACKGROUND_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2BackgroundChildStopRecoveryRaceInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_SUBAGENT_BACKGROUND_PROMPT }, + { + type: "interrupt_provider_native", + subagentNativeItemId: "tool:call_opencode2_background_child_stop_recovery_race", + }, + { type: "message", text: "Recover after cancellation. Respond exactly RECOVERY_OK" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_race/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_race/opencode2_transcript.ndjson new file mode 100644 index 00000000000..4b88a072bb5 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_race/opencode2_transcript.ndjson @@ -0,0 +1,59 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16540","scenario":"opencode2_background_child_stop_recovery_race","metadata":{"source":"focused-provider-native-stop","description":"A cancelled synthetic parent admission arrives after an ordinary recovery input has a native input id. The recovery execution must own the shared boundary and complete without cancelled continuation output."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_background_child_stop_recovery_race","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785394800000,"updated":1785394800000},"title":"T3 OpenCode 2 background child stop recovery race replay","location":{"directory":"/private/tmp/t3-opencode2-background-child-stop-recovery-race"}}}} +{"type":"expect_outbound","label":"session.prompt.root","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","text":"Start one background subagent with description background child fixture and prompt Respond exactly CHILD_BACKGROUND_OK. Then respond exactly PARENT_RELEASED without waiting for the child."}}} +{"type":"emit_inbound","label":"session.prompt.root.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_background_child_stop_recovery_race_root"}}} +{"type":"emit_inbound","label":"root.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","inputID":"input_opencode2_background_child_stop_recovery_race_root","input":{"type":"user","data":{"text":"Start one background subagent"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"root.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","inputID":"input_opencode2_background_child_stop_recovery_race_root"}}}} +{"type":"emit_inbound","label":"root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race"}}}} +{"type":"emit_inbound","label":"subagent.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_root","ordinal":0,"callID":"call_opencode2_background_child_stop_recovery_race","name":"subagent"}}}} +{"type":"emit_inbound","label":"subagent.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_root","ordinal":0,"callID":"call_opencode2_background_child_stop_recovery_race","input":{"agent":"explore","background":true,"description":"background child fixture","prompt":"Respond exactly CHILD_BACKGROUND_OK"}}}}} +{"type":"emit_inbound","label":"subagent.launch.success","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_root","ordinal":0,"callID":"call_opencode2_background_child_stop_recovery_race","content":[{"type":"text","text":"Background subagent launched"}],"structured":{"sessionID":"ses_opencode2_background_child_stop_recovery_race_child"}}}}} +{"type":"emit_inbound","label":"child.session.created","frame":{"type":"sdk.event","event":{"id":"event_opencode2_background_child_stop_recovery_race_child","created":1785394800100,"type":"session.created","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race_child","info":{"id":"ses_opencode2_background_child_stop_recovery_race_child","slug":"background-child-stop-recovery-race","projectID":"global","directory":"/private/tmp/t3-opencode2-background-child-stop-recovery-race","parentID":"ses_opencode2_background_child_stop_recovery_race","title":"background child fixture","agent":"explore","model":{"id":"big-pickle","providerID":"opencode"},"version":"0.0.0-next-16540","time":{"created":1785394800100,"updated":1785394800100}}}}}} +{"type":"emit_inbound","label":"child.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race_child","inputID":"input_opencode2_background_child_stop_recovery_race_child","input":{"type":"user","data":{"text":"Respond exactly CHILD_BACKGROUND_OK"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"child.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race_child","inputID":"input_opencode2_background_child_stop_recovery_race_child"}}}} +{"type":"emit_inbound","label":"child.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race_child"}}}} +{"type":"emit_inbound","label":"child.tool.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race_child","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_child","ordinal":0,"callID":"call_opencode2_background_child_stop_recovery_race_shell","name":"bash"}}}} +{"type":"emit_inbound","label":"child.tool.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race_child","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_child","ordinal":0,"callID":"call_opencode2_background_child_stop_recovery_race_shell","input":{"command":"sleep 30 && echo child partial"}}}}} +{"type":"emit_inbound","label":"child.shell.created","frame":{"type":"sdk.event","event":{"type":"shell.created","data":{"info":{"id":"shell_opencode2_background_child_stop_recovery_race","status":"running","command":"sleep 30 && echo child partial","cwd":"/private/tmp/t3-opencode2-background-child-stop-recovery-race","shell":"/bin/bash","file":"/private/tmp/t3-opencode2-background-child-stop-recovery-race/shell.log","pid":4246,"metadata":{"sessionID":"ses_opencode2_background_child_stop_recovery_race_child"},"time":{"started":1785394800200}}}}}} +{"type":"emit_inbound","label":"child.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race_child","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_partial","ordinal":1}}}} +{"type":"emit_inbound","label":"child.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race_child","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_partial","ordinal":1,"delta":"CHILD_PARTIAL"}}}} +{"type":"emit_inbound","label":"child.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race_child","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_partial","ordinal":1,"text":"CHILD_PARTIAL"}}}} +{"type":"emit_inbound","label":"root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_root","ordinal":1}}}} +{"type":"emit_inbound","label":"root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_root","ordinal":1,"delta":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_root","ordinal":1,"text":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race"}}}} +{"type":"expect_outbound","label":"child.session.interrupt","frame":{"type":"session.interrupt","input":{"sessionID":"ses_opencode2_background_child_stop_recovery_race_child"}}} +{"type":"emit_inbound","label":"child.session.interrupt.response","frame":{"type":"sdk.response","operation":"session.interrupt","data":true}} +{"type":"expect_outbound","label":"child.shell.remove","frame":{"type":"shell.remove","input":{"id":"shell_opencode2_background_child_stop_recovery_race","location":{"directory":"/private/tmp/t3-opencode2-background-child-stop-recovery-race"}}}} +{"type":"emit_inbound","label":"child.shell.remove.response","frame":{"type":"sdk.response","operation":"shell.remove","data":true}} +{"type":"emit_inbound","label":"child.shell.deleted","frame":{"type":"sdk.event","event":{"type":"shell.deleted","data":{"id":"shell_opencode2_background_child_stop_recovery_race"}}}} +{"type":"emit_inbound","label":"child.tool.failed","frame":{"type":"sdk.event","event":{"type":"session.tool.failed","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race_child","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_child","callID":"call_opencode2_background_child_stop_recovery_race_shell","error":{"type":"ToolExecutionError","message":"Tool execution interrupted"},"executed":true}}}} +{"type":"emit_inbound","label":"cancelled.parent.wake.first","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","inputID":"input_opencode2_background_child_stop_recovery_race_cancelled","input":{"type":"synthetic","data":{"text":"CHILD_PARTIAL","description":"background child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"cancelled.parent.wake.second","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","inputID":"input_opencode2_background_child_stop_recovery_race_cancelled_second","input":{"type":"synthetic","data":{"text":"CHILD_PARTIAL_SECOND","description":"background child fixture second"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"child.execution.interrupted","frame":{"type":"sdk.event","event":{"type":"session.execution.interrupted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race_child","reason":"user"}}}} +{"type":"expect_outbound","label":"root.pending.list.after.stop","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_background_child_stop_recovery_race"}}} +{"type":"emit_inbound","label":"root.pending.list.after.stop.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list.after.stop","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-background-child-stop-recovery-race"}}}} +{"type":"emit_inbound","label":"root.shell.list.after.stop.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"expect_outbound","label":"session.prompt.recovery","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","text":"Recover after cancellation. Respond exactly RECOVERY_OK"}}} +{"type":"emit_inbound","label":"session.prompt.recovery.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_background_child_stop_recovery_race_recovery"}}} +{"type":"emit_inbound","label":"recovery.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","inputID":"input_opencode2_background_child_stop_recovery_race_recovery","input":{"type":"user","data":{"text":"Recover after cancellation. Respond exactly RECOVERY_OK"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"cancelled.input.promoted.first","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","inputID":"input_opencode2_background_child_stop_recovery_race_cancelled"}}}} +{"type":"emit_inbound","label":"cancelled.input.promoted.second","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","inputID":"input_opencode2_background_child_stop_recovery_race_cancelled_second"}}}} +{"type":"emit_inbound","label":"recovery.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","inputID":"input_opencode2_background_child_stop_recovery_race_recovery"}}}} +{"type":"emit_inbound","label":"cancelled.parent.wake.late","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","inputID":"input_opencode2_background_child_stop_recovery_race_cancelled_late","input":{"type":"synthetic","data":{"text":"CANCELLED_CONTINUATION_MUST_NOT_APPEAR","description":"late cancelled background child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"cancelled.input.promoted.late","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","inputID":"input_opencode2_background_child_stop_recovery_race_cancelled_late"}}}} +{"type":"emit_inbound","label":"recovery.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race"}}}} +{"type":"emit_inbound","label":"recovery.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_recovery","ordinal":0}}}} +{"type":"emit_inbound","label":"recovery.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_recovery","ordinal":0,"delta":"RECOVERY_OK"}}}} +{"type":"emit_inbound","label":"recovery.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race","assistantMessageID":"message_opencode2_background_child_stop_recovery_race_recovery","ordinal":0,"text":"RECOVERY_OK"}}}} +{"type":"emit_inbound","label":"recovery.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_background_child_stop_recovery_race"}}}} +{"type":"expect_outbound","label":"root.pending.list.after.recovery","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_background_child_stop_recovery_race"}}} +{"type":"emit_inbound","label":"root.pending.list.after.recovery.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list.after.recovery","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-background-child-stop-recovery-race"}}}} +{"type":"emit_inbound","label":"root.shell.list.after.recovery.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_race/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_race/output.ts new file mode 100644 index 00000000000..3f30dc9e24f --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_child_stop_recovery_race/output.ts @@ -0,0 +1,46 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +function transcriptIndex(transcript: ProviderReplayTranscript, label: string): number { + return transcript.entries.findIndex( + (entry) => entry.type !== "runtime_exit" && entry.label === label, + ); +} + +export function assertOpenCode2BackgroundChildStopRecoveryRaceOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + const recoveryAdmitted = transcriptIndex(transcript, "recovery.input.admitted"); + const lateCancellationAdmitted = transcriptIndex(transcript, "cancelled.parent.wake.late"); + const recoveryStarted = transcriptIndex(transcript, "recovery.execution.started"); + assert.isAtLeast(recoveryAdmitted, 0); + assert.isAtLeast(lateCancellationAdmitted, 0); + assert.isAtLeast(recoveryStarted, 0); + assert.isAbove(lateCancellationAdmitted, recoveryAdmitted); + assert.isAbove(recoveryStarted, lateCancellationAdmitted); + + const projection = projectionFor(result, transcript.scenario); + assertBaseProjection({ + result, + transcript, + runCount: 2, + runStatuses: ["completed", "completed"], + }); + assertSemanticProjectionIntegrity(projection); + assertAssistantTextIncludes(projection, "RECOVERY_OK"); + assert.notInclude( + projection.messages.map((message) => message.text).join("\n"), + "CANCELLED_CONTINUATION_MUST_NOT_APPEAR", + ); + assert.notInclude(JSON.stringify(projection), "CANCELLED_CONTINUATION_MUST_NOT_APPEAR"); + assert.notInclude(JSON.stringify(projection), "CHILD_PARTIAL_SECOND"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_stop/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_stop/input.ts new file mode 100644 index 00000000000..3a622590f8e --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_stop/input.ts @@ -0,0 +1,10 @@ +import { OPENCODE2_BACKGROUND_STOP_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2BackgroundStopInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_BACKGROUND_STOP_PROMPT }, + { type: "interrupt", targetRunIndex: 1, waitForTurnItemType: "command_execution" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_stop/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_stop/opencode2_transcript.ndjson new file mode 100644 index 00000000000..056a95d5371 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_stop/opencode2_transcript.ndjson @@ -0,0 +1,21 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16383","scenario":"opencode2_background_stop","metadata":{"source":"derived-from-live-run","capturedAt":"2026-07-29","nativeSessionId":"ses_opencode2_background_stop","model":"opencode/big-pickle","description":"Stop after a model shell has become native background work. OpenCode 2 session interrupt leaves that shell alive by itself, so the adapter must remove the exact turn-owned shell and retain one interrupted command row."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_background_stop","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785297600000,"updated":1785297600000},"title":"T3 OpenCode 2 background Stop replay","location":{"directory":"/private/tmp/t3-opencode2-background-stop"}}}} +{"type":"expect_outbound","label":"session.prompt","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_background_stop","text":"Run this shell command and wait for it: sleep 30 && echo background stop should not finish. Do not respond before it completes."}}} +{"type":"emit_inbound","label":"session.prompt.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_background_stop"}}} +{"type":"emit_inbound","label":"session.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_stop","inputID":"input_opencode2_background_stop","input":{"id":"input_opencode2_background_stop","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_background_stop"}}}} +{"type":"emit_inbound","label":"session.tool.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_background_stop","assistantMessageID":"message_opencode2_background_stop","ordinal":0,"callID":"call_opencode2_background_stop","name":"bash"}}}} +{"type":"emit_inbound","label":"session.tool.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_background_stop","assistantMessageID":"message_opencode2_background_stop","ordinal":0,"callID":"call_opencode2_background_stop","input":{"command":"sleep 30 && echo background stop should not finish"}}}}} +{"type":"emit_inbound","label":"shell.created","frame":{"type":"sdk.event","event":{"type":"shell.created","data":{"info":{"id":"shell_opencode2_background_stop","status":"running","command":"sleep 30 && echo background stop should not finish","cwd":"/private/tmp/t3-opencode2-background-stop","shell":"/bin/bash","file":"/private/tmp/t3-opencode2-background-stop/shell.log","pid":4245,"metadata":{"sessionID":"ses_opencode2_background_stop"},"time":{"started":1785297600100}}}}}} +{"type":"expect_outbound","label":"session.interrupt","frame":{"type":"session.interrupt","input":{"sessionID":"ses_opencode2_background_stop"}}} +{"type":"emit_inbound","label":"session.interrupt.response","frame":{"type":"sdk.response","operation":"session.interrupt","data":true}} +{"type":"expect_outbound","label":"shell.remove","frame":{"type":"shell.remove","input":{"id":"shell_opencode2_background_stop","location":{"directory":"/private/tmp/t3-opencode2-background-stop"}}}} +{"type":"emit_inbound","label":"shell.remove.response","frame":{"type":"sdk.response","operation":"shell.remove","data":true}} +{"type":"emit_inbound","label":"shell.deleted","frame":{"type":"sdk.event","event":{"type":"shell.deleted","data":{"id":"shell_opencode2_background_stop"}}}} +{"type":"emit_inbound","label":"session.tool.failed","frame":{"type":"sdk.event","event":{"type":"session.tool.failed","data":{"sessionID":"ses_opencode2_background_stop","assistantMessageID":"message_opencode2_background_stop","callID":"call_opencode2_background_stop","error":{"type":"ToolExecutionError","message":"Tool execution interrupted"},"executed":true}}}} +{"type":"emit_inbound","label":"session.execution.interrupted","frame":{"type":"sdk.event","event":{"type":"session.execution.interrupted","data":{"sessionID":"ses_opencode2_background_stop","reason":"user"}}}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_stop/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_stop/output.ts new file mode 100644 index 00000000000..64cfa678f6d --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_background_stop/output.ts @@ -0,0 +1,54 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertTurnItemTypes, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_BACKGROUND_STOP_PROMPT, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2BackgroundStopOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + const shellCreatedIndex = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "shell.created", + ); + const interruptIndex = transcript.entries.findIndex( + (entry) => entry.type === "expect_outbound" && entry.label === "session.interrupt", + ); + const shellRemoveIndex = transcript.entries.findIndex( + (entry) => entry.type === "expect_outbound" && entry.label === "shell.remove", + ); + assert.isAtLeast(shellCreatedIndex, 0); + assert.isAbove(interruptIndex, shellCreatedIndex); + assert.isAbove(shellRemoveIndex, interruptIndex); + + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["interrupted"] }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertTurnItemTypes(projection, [ + "user_message", + "command_execution", + "run_interrupt_request", + "run_interrupt_result", + ]); + assertUserMessagesInclude(projection, [OPENCODE2_BACKGROUND_STOP_PROMPT]); + + const commands = projection.turnItems.filter((item) => item.type === "command_execution"); + assert.equal(commands.length, 1, "tool and shell events must retain one interrupted command row"); + assert.equal(commands[0]?.status, "interrupted"); + assert.include(commands[0]?.input ?? "", "background stop should not finish"); + assert.notInclude(commands[0]?.output ?? "", "background stop should not finish"); + assert.deepEqual( + projection.attempts.map((attempt) => attempt.status), + ["interrupted"], + ); + assert.equal(projection.providerTurns[0]?.status, "interrupted"); +} 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 00000000000..61a2088c427 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/input.ts @@ -0,0 +1,15 @@ +import { + OPENCODE2_COMPACTION_INTERRUPT_PROMPT, + OPENCODE2_COMPACTION_PROMPT, + type OrchestratorFixtureInput, +} from "../shared.ts"; + +export function openCode2CompactionInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_COMPACTION_PROMPT }, + { type: "message", text: OPENCODE2_COMPACTION_INTERRUPT_PROMPT }, + { type: "interrupt", targetRunIndex: 2, waitForTurnItemType: "compaction" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/opencode2_transcript.ndjson new file mode 100644 index 00000000000..53922a20789 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/opencode2_transcript.ndjson @@ -0,0 +1,32 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16383","scenario":"opencode2_compaction","metadata":{"source":"sdk-contract-verified-live","capturedAt":"2026-07-29","nativeSessionId":"ses_opencode2_compaction","model":"opencode/big-pickle","description":"An automatic compaction streams a summary into one lifecycle row before the active turn completes, then an interrupted compaction closes its own row without leaving a spinner."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_compaction","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785297600000,"updated":1785297600000},"title":"T3 OpenCode 2 compaction replay","location":{"directory":"/private/tmp/t3-opencode2-compaction-replay"}}}} +{"type":"expect_outbound","label":"session.prompt","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_compaction","text":"Compact the current context, then respond exactly: compaction fixture complete"}}} +{"type":"emit_inbound","label":"session.prompt.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_compaction"}}} +{"type":"emit_inbound","label":"session.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_compaction","inputID":"input_opencode2_compaction","input":{"id":"input_opencode2_compaction","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_compaction"}}}} +{"type":"emit_inbound","label":"session.compaction.admitted","frame":{"type":"sdk.event","event":{"id":"event_compaction_admitted","created":1785297600100,"type":"session.compaction.admitted","data":{"sessionID":"ses_opencode2_compaction","inputID":"input_compaction_summary"}}}} +{"type":"emit_inbound","label":"session.compaction.started","frame":{"type":"sdk.event","event":{"id":"event_compaction_started","created":1785297600200,"type":"session.compaction.started","data":{"sessionID":"ses_opencode2_compaction","reason":"auto","recent":"message_opencode2_compaction","inputID":"input_compaction_summary"}}}} +{"type":"emit_inbound","label":"session.compaction.delta.first","frame":{"type":"sdk.event","event":{"id":"event_compaction_delta_1","created":1785297600300,"type":"session.compaction.delta","data":{"sessionID":"ses_opencode2_compaction","text":"Summary from "}}}} +{"type":"emit_inbound","label":"session.compaction.delta.second","frame":{"type":"sdk.event","event":{"id":"event_compaction_delta_2","created":1785297600400,"type":"session.compaction.delta","data":{"sessionID":"ses_opencode2_compaction","text":"compaction."}}}} +{"type":"emit_inbound","label":"session.compaction.ended","frame":{"type":"sdk.event","event":{"id":"event_compaction_ended","created":1785297600500,"type":"session.compaction.ended","data":{"sessionID":"ses_opencode2_compaction","reason":"auto","text":"Summary from compaction.","recent":"message_opencode2_compaction"}}}} +{"type":"emit_inbound","label":"session.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_compaction","assistantMessageID":"message_opencode2_compaction","ordinal":0}}}} +{"type":"emit_inbound","label":"session.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_compaction","assistantMessageID":"message_opencode2_compaction","ordinal":0,"text":"compaction fixture complete"}}}} +{"type":"emit_inbound","label":"session.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_compaction"}}}} +{"type":"expect_outbound","label":"session.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_compaction"}}} +{"type":"emit_inbound","label":"session.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-compaction-replay"}}}} +{"type":"emit_inbound","label":"shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"expect_outbound","label":"session.prompt.interrupt","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_compaction","text":"Begin compacting the current context and wait for it to finish."}}} +{"type":"emit_inbound","label":"session.prompt.interrupt.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_compaction_interrupt"}}} +{"type":"emit_inbound","label":"session.input.admitted.interrupt","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_compaction","inputID":"input_opencode2_compaction_interrupt","input":{"id":"input_opencode2_compaction_interrupt","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started.interrupt","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_compaction"}}}} +{"type":"emit_inbound","label":"session.compaction.started.interrupt","frame":{"type":"sdk.event","event":{"id":"event_compaction_started_interrupt","created":1785297600600,"type":"session.compaction.started","data":{"sessionID":"ses_opencode2_compaction","reason":"auto","recent":"message_opencode2_compaction","inputID":"input_compaction_interrupt"}}}} +{"type":"emit_inbound","label":"session.compaction.delta.interrupt","frame":{"type":"sdk.event","event":{"id":"event_compaction_delta_interrupt","created":1785297600700,"type":"session.compaction.delta","data":{"sessionID":"ses_opencode2_compaction","text":"Partial summary"}}}} +{"type":"expect_outbound","label":"session.interrupt","frame":{"type":"session.interrupt","input":{"sessionID":"ses_opencode2_compaction"}}} +{"type":"emit_inbound","label":"session.interrupt.response","frame":{"type":"sdk.response","operation":"session.interrupt","data":true}} +{"type":"emit_inbound","label":"session.execution.interrupted","frame":{"type":"sdk.event","event":{"type":"session.execution.interrupted","data":{"sessionID":"ses_opencode2_compaction","reason":"user"}}}} +{"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 00000000000..78da09d9f4f --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/output.ts @@ -0,0 +1,56 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertConversationMessageRoles, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_COMPACTION_INTERRUPT_PROMPT, + OPENCODE2_COMPACTION_PROMPT, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2CompactionOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ + result, + transcript, + runCount: 2, + runStatuses: ["completed", "interrupted"], + }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertConversationMessageRoles(projection, ["user", "assistant", "user"]); + assertUserMessagesInclude(projection, [ + OPENCODE2_COMPACTION_PROMPT, + OPENCODE2_COMPACTION_INTERRUPT_PROMPT, + ]); + assertAssistantTextIncludes(projection, "compaction fixture complete"); + + const compactions = projection.turnItems.filter((item) => item.type === "compaction"); + assert.equal(compactions.length, 2, "each lifecycle must retain one stable compaction row"); + const completed = compactions.find((compaction) => compaction.status === "completed"); + assert.isDefined(completed); + assert.equal(completed.driver, "opencode2"); + assert.equal(completed.summary, "Summary from compaction."); + + const interrupted = compactions.find((compaction) => compaction.status === "cancelled"); + assert.isDefined(interrupted); + assert.equal(interrupted.driver, "opencode2"); + assert.equal(interrupted.summary, "Partial summary"); + + for (const compaction of compactions) { + const node = projection.nodes.find((candidate) => candidate.id === compaction.nodeId); + assert.isDefined(node); + assert.equal(node.kind, "system"); + assert.equal(node.status, compaction.status); + } +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_external_subagent/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_external_subagent/input.ts new file mode 100644 index 00000000000..1255730b42d --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_external_subagent/input.ts @@ -0,0 +1,7 @@ +import { OPENCODE2_SUBAGENT_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2PermissionExternalSubagentInput(): OrchestratorFixtureInput { + return { + steps: [{ type: "message", text: OPENCODE2_SUBAGENT_PROMPT }], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_external_subagent/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_external_subagent/opencode2_transcript.ndjson new file mode 100644 index 00000000000..ece91806b1d --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_external_subagent/opencode2_transcript.ndjson @@ -0,0 +1,33 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16558","scenario":"opencode2_permission_external_subagent","metadata":{"source":"derived-from-live-run","capturedAt":"2026-07-30","nativeSessionId":"ses_opencode2_permission_external_subagent","model":"opencode/big-pickle","description":"A full-access root session spawns a native child whose legacy-named external_directory permission ask is auto-allowed through the v2 session-scoped reply route."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_permission_external_subagent","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785457422000,"updated":1785457422000},"title":"T3 OpenCode 2 external permission replay","location":{"directory":"/private/tmp/t3-opencode2-permission-external-subagent"}}}} +{"type":"expect_outbound","label":"session.prompt","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_permission_external_subagent","text":"Use the subagent tool exactly once with description child fixture and prompt Respond exactly CHILD_OK. Then respond exactly PARENT_OK."}}} +{"type":"emit_inbound","label":"session.prompt.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_permission_external_subagent"}}} +{"type":"emit_inbound","label":"root.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_permission_external_subagent","inputID":"input_opencode2_permission_external_subagent","input":{"id":"input_opencode2_permission_external_subagent","type":"user"}}}}} +{"type":"emit_inbound","label":"root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_permission_external_subagent"}}}} +{"type":"emit_inbound","label":"subagent.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_permission_external_subagent","assistantMessageID":"message_opencode2_permission_external_subagent","ordinal":0,"callID":"call_opencode2_permission_external_subagent","name":"subagent"}}}} +{"type":"emit_inbound","label":"subagent.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_permission_external_subagent","assistantMessageID":"message_opencode2_permission_external_subagent","ordinal":0,"callID":"call_opencode2_permission_external_subagent","input":{"agent":"explore","description":"child fixture","prompt":"Respond exactly CHILD_OK"}}}}} +{"type":"emit_inbound","label":"child.session.created","frame":{"type":"sdk.event","event":{"id":"event_child_created_external_permission","created":1785457422800,"type":"session.created","data":{"sessionID":"ses_opencode2_child_external_permission","info":{"id":"ses_opencode2_child_external_permission","slug":"child-external-permission","projectID":"global","directory":"/private/tmp/project-a","parentID":"ses_opencode2_permission_external_subagent","title":"child fixture","agent":"explore","model":{"id":"big-pickle","providerID":"opencode"},"version":"0.0.0-next-16558","time":{"created":1785457422800,"updated":1785457422800}}}}}} +{"type":"emit_inbound","label":"child.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_child_external_permission","inputID":"input_opencode2_child_external_permission","input":{"id":"input_opencode2_child_external_permission","type":"user"}}}}} +{"type":"emit_inbound","label":"child.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_child_external_permission"}}}} +{"type":"emit_inbound","label":"child.permission.asked.external_directory","frame":{"type":"sdk.event","event":{"id":"evt_opencode2_child_external_permission","created":1785457422825,"type":"permission.asked","location":{"directory":"/private/tmp/project-a"},"data":{"id":"permission_opencode2_child_external_directory","sessionID":"ses_opencode2_child_external_permission","action":"external_directory","resources":["/private/tmp/project-b/*"],"save":["/private/tmp/project-b/*"],"source":{"type":"tool","messageID":"message_opencode2_permission_external_subagent","callID":"call_opencode2_permission_external_subagent"}}}}} +{"type":"expect_outbound","label":"child.session.permission.reply.external_directory","frame":{"type":"session.permission.reply","input":{"sessionID":"ses_opencode2_child_external_permission","requestID":"permission_opencode2_child_external_directory","reply":"once"}}} +{"type":"emit_inbound","label":"child.session.permission.reply.external_directory.response","frame":{"type":"sdk.response","operation":"session.permission.reply","data":null}} +{"type":"emit_inbound","label":"child.permission.replied","frame":{"type":"sdk.event","event":{"id":"event_child_permission_replied_external_permission","created":1785457422900,"type":"permission.replied","data":{"sessionID":"ses_opencode2_child_external_permission","requestID":"permission_opencode2_child_external_directory","reply":"once"}}}} +{"type":"emit_inbound","label":"child.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_child_external_permission","assistantMessageID":"message_opencode2_child_external_permission","ordinal":0}}}} +{"type":"emit_inbound","label":"child.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_child_external_permission","assistantMessageID":"message_opencode2_child_external_permission","ordinal":0,"delta":"CHILD_OK"}}}} +{"type":"emit_inbound","label":"child.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_child_external_permission","assistantMessageID":"message_opencode2_child_external_permission","ordinal":0,"text":"CHILD_OK"}}}} +{"type":"emit_inbound","label":"child.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_child_external_permission"}}}} +{"type":"emit_inbound","label":"subagent.success","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_permission_external_subagent","assistantMessageID":"message_opencode2_permission_external_subagent","ordinal":0,"callID":"call_opencode2_permission_external_subagent","content":[{"type":"text","text":"CHILD_OK"}],"structured":{"sessionID":"ses_opencode2_child_external_permission"}}}}} +{"type":"emit_inbound","label":"root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_permission_external_subagent","assistantMessageID":"message_opencode2_parent_external_permission","ordinal":1}}}} +{"type":"emit_inbound","label":"root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_permission_external_subagent","assistantMessageID":"message_opencode2_parent_external_permission","ordinal":1,"delta":"PARENT_OK"}}}} +{"type":"emit_inbound","label":"root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_permission_external_subagent","assistantMessageID":"message_opencode2_parent_external_permission","ordinal":1,"text":"PARENT_OK"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_permission_external_subagent"}}}} +{"type":"expect_outbound","label":"root.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_permission_external_subagent"}}} +{"type":"emit_inbound","label":"root.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-permission-external-subagent"}}}} +{"type":"emit_inbound","label":"root.shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_external_subagent/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_external_subagent/output.ts new file mode 100644 index 00000000000..67a0c5414e4 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_external_subagent/output.ts @@ -0,0 +1,37 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAllRuntimeRequestsResolved, + assertAssistantTextIncludes, + assertBaseProjection, + assertRuntimeRequestCounts, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2PermissionExternalSubagentOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + const item = projection.turnItems.find((candidate) => candidate.type === "subagent"); + assert.strictEqual(item?.type, "subagent"); + if (item?.type !== "subagent") throw new Error("OpenCode 2 subagent item is missing"); + assert.strictEqual(item.status, "completed"); + assert.isNotNull(item.childThreadId); + const child = result.projections.get(item.childThreadId!); + assert.isDefined(child); + assert.strictEqual(child!.thread.lineage.parentThreadId, projection.thread.id); + assert.strictEqual(child!.thread.lineage.relationshipToParent, "subagent"); + assertRuntimeRequestCounts(projection, { total: 0 }); + assertAllRuntimeRequestsResolved(projection); + assertRuntimeRequestCounts(child!, { total: 0 }); + assertAssistantTextIncludes(child!, "CHILD_OK"); + assertAssistantTextIncludes(projection, "PARENT_OK"); + assert.strictEqual(projection.subagents[0]?.status, "completed"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure/input.ts new file mode 100644 index 00000000000..63b4de2df3d --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure/input.ts @@ -0,0 +1,10 @@ +import { SIMPLE_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2PermissionReplyFailureInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: SIMPLE_PROMPT }, + { type: "approve_next_runtime_request", decision: "accept" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure/opencode2_transcript.ndjson new file mode 100644 index 00000000000..254408b77a7 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure/opencode2_transcript.ndjson @@ -0,0 +1,25 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16558","scenario":"opencode2_permission_reply_failure","metadata":{"source":"replay","capturedAt":"2026-07-30","nativeSessionId":"ses_opencode2_permission_reply_failure","model":"opencode/big-pickle","description":"A failed automatic session-scoped permission reply falls back to a visible runtime approval request and completes after the user accepts it."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_permission_reply_failure","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785457423000,"updated":1785457423000},"title":"T3 OpenCode 2 permission reply failure replay","location":{"directory":"/private/tmp/t3-opencode2-permission-reply-failure"}}}} +{"type":"expect_outbound","label":"session.prompt","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_permission_reply_failure","text":"Respond with the following text: fixture simple ok"}}} +{"type":"emit_inbound","label":"session.prompt.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_permission_reply_failure"}}} +{"type":"emit_inbound","label":"session.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_permission_reply_failure","inputID":"input_opencode2_permission_reply_failure","input":{"id":"input_opencode2_permission_reply_failure","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_permission_reply_failure"}}}} +{"type":"emit_inbound","label":"permission.asked","frame":{"type":"sdk.event","event":{"id":"event_opencode2_permission_reply_failure","created":1785457423100,"type":"permission.asked","data":{"id":"permission_opencode2_permission_reply_failure","sessionID":"ses_opencode2_permission_reply_failure","permission":"bash","patterns":[],"metadata":{},"always":[]}}}} +{"type":"expect_outbound","label":"session.permission.reply.failed","frame":{"type":"session.permission.reply","input":{"sessionID":"ses_opencode2_permission_reply_failure","requestID":"permission_opencode2_permission_reply_failure","reply":"once"}}} +{"type":"emit_inbound","label":"session.permission.reply.failed.response","frame":{"type":"sdk.error","operation":"session.permission.reply","message":"replay permission route failed","error":{"status":404}}} +{"type":"expect_outbound","label":"session.permission.reply.approved","frame":{"type":"session.permission.reply","input":{"sessionID":"ses_opencode2_permission_reply_failure","requestID":"permission_opencode2_permission_reply_failure","reply":"once"}}} +{"type":"emit_inbound","label":"session.permission.reply.approved.response","frame":{"type":"sdk.response","operation":"session.permission.reply","data":null}} +{"type":"emit_inbound","label":"permission.replied","frame":{"type":"sdk.event","event":{"id":"event_opencode2_permission_reply_failure_replied","created":1785457423200,"type":"permission.replied","data":{"sessionID":"ses_opencode2_permission_reply_failure","requestID":"permission_opencode2_permission_reply_failure","reply":"once"}}}} +{"type":"emit_inbound","label":"session.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_permission_reply_failure","assistantMessageID":"message_opencode2_permission_reply_failure","ordinal":0}}}} +{"type":"emit_inbound","label":"session.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_permission_reply_failure","assistantMessageID":"message_opencode2_permission_reply_failure","ordinal":0,"delta":"fixture simple ok"}}}} +{"type":"emit_inbound","label":"session.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_permission_reply_failure","assistantMessageID":"message_opencode2_permission_reply_failure","ordinal":0,"text":"fixture simple ok"}}}} +{"type":"emit_inbound","label":"session.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_permission_reply_failure"}}}} +{"type":"expect_outbound","label":"session.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_permission_reply_failure"}}} +{"type":"emit_inbound","label":"session.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-permission-reply-failure"}}}} +{"type":"emit_inbound","label":"shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure/output.ts new file mode 100644 index 00000000000..07d88b8c3d3 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure/output.ts @@ -0,0 +1,24 @@ +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAllRuntimeRequestsResolved, + assertAssistantTextIncludes, + assertBaseProjection, + assertRuntimeRequestCounts, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2PermissionReplyFailureOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertRuntimeRequestCounts(projection, { total: 1, resolved: 1 }); + assertAllRuntimeRequestsResolved(projection); + assertAssistantTextIncludes(projection, "fixture simple ok"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure_subagent/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure_subagent/input.ts new file mode 100644 index 00000000000..32e6cbded99 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure_subagent/input.ts @@ -0,0 +1,10 @@ +import { OPENCODE2_SUBAGENT_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2PermissionReplyFailureSubagentInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_SUBAGENT_PROMPT }, + { type: "approve_next_runtime_request", decision: "accept" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure_subagent/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure_subagent/opencode2_transcript.ndjson new file mode 100644 index 00000000000..c17807fdb79 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure_subagent/opencode2_transcript.ndjson @@ -0,0 +1,35 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16558","scenario":"opencode2_permission_reply_failure_subagent","metadata":{"source":"replay","capturedAt":"2026-07-30","nativeSessionId":"ses_opencode2_permission_reply_failure_subagent","model":"opencode/big-pickle","description":"A failed automatic permission reply on a native child falls back to one visible runtime approval request and completes after the user accepts it."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_permission_reply_failure_subagent","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785457424000,"updated":1785457424000},"title":"T3 OpenCode 2 child permission reply failure replay","location":{"directory":"/private/tmp/t3-opencode2-permission-reply-failure-subagent"}}}} +{"type":"expect_outbound","label":"session.prompt","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_permission_reply_failure_subagent","text":"Use the subagent tool exactly once with description child fixture and prompt Respond exactly CHILD_OK. Then respond exactly PARENT_OK."}}} +{"type":"emit_inbound","label":"session.prompt.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_permission_reply_failure_subagent"}}} +{"type":"emit_inbound","label":"root.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_permission_reply_failure_subagent","inputID":"input_opencode2_permission_reply_failure_subagent","input":{"id":"input_opencode2_permission_reply_failure_subagent","type":"user"}}}}} +{"type":"emit_inbound","label":"root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_permission_reply_failure_subagent"}}}} +{"type":"emit_inbound","label":"subagent.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_permission_reply_failure_subagent","assistantMessageID":"message_opencode2_permission_reply_failure_subagent","ordinal":0,"callID":"call_opencode2_permission_reply_failure_subagent","name":"subagent"}}}} +{"type":"emit_inbound","label":"subagent.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_permission_reply_failure_subagent","assistantMessageID":"message_opencode2_permission_reply_failure_subagent","ordinal":0,"callID":"call_opencode2_permission_reply_failure_subagent","input":{"agent":"explore","description":"child fixture","prompt":"Respond exactly CHILD_OK"}}}}} +{"type":"emit_inbound","label":"child.session.created","frame":{"type":"sdk.event","event":{"id":"event_child_created_permission_reply_failure","created":1785457424800,"type":"session.created","data":{"sessionID":"ses_opencode2_child_permission_reply_failure","info":{"id":"ses_opencode2_child_permission_reply_failure","slug":"child-permission-reply-failure","projectID":"global","directory":"/private/tmp/project-a","parentID":"ses_opencode2_permission_reply_failure_subagent","title":"child fixture","agent":"explore","model":{"id":"big-pickle","providerID":"opencode"},"version":"0.0.0-next-16558","time":{"created":1785457424800,"updated":1785457424800}}}}}} +{"type":"emit_inbound","label":"child.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_child_permission_reply_failure","inputID":"input_opencode2_child_permission_reply_failure","input":{"id":"input_opencode2_child_permission_reply_failure","type":"user"}}}}} +{"type":"emit_inbound","label":"child.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_child_permission_reply_failure"}}}} +{"type":"emit_inbound","label":"child.permission.asked","frame":{"type":"sdk.event","event":{"id":"event_opencode2_child_permission_reply_failure","created":1785457424825,"type":"permission.asked","location":{"directory":"/private/tmp/project-a"},"data":{"id":"permission_opencode2_child_permission_reply_failure","sessionID":"ses_opencode2_child_permission_reply_failure","permission":"bash","patterns":[],"metadata":{},"always":[]}}}} +{"type":"expect_outbound","label":"child.session.permission.reply.failed","frame":{"type":"session.permission.reply","input":{"sessionID":"ses_opencode2_child_permission_reply_failure","requestID":"permission_opencode2_child_permission_reply_failure","reply":"once"}}} +{"type":"emit_inbound","label":"child.session.permission.reply.failed.response","frame":{"type":"sdk.error","operation":"session.permission.reply","message":"replay child permission route failed","error":{"status":404}}} +{"type":"expect_outbound","label":"child.session.permission.reply.approved","frame":{"type":"session.permission.reply","input":{"sessionID":"ses_opencode2_child_permission_reply_failure","requestID":"permission_opencode2_child_permission_reply_failure","reply":"once"}}} +{"type":"emit_inbound","label":"child.session.permission.reply.approved.response","frame":{"type":"sdk.response","operation":"session.permission.reply","data":null}} +{"type":"emit_inbound","label":"child.permission.replied","frame":{"type":"sdk.event","event":{"id":"event_opencode2_child_permission_reply_failure_replied","created":1785457424900,"type":"permission.replied","data":{"sessionID":"ses_opencode2_child_permission_reply_failure","requestID":"permission_opencode2_child_permission_reply_failure","reply":"once"}}}} +{"type":"emit_inbound","label":"child.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_child_permission_reply_failure","assistantMessageID":"message_opencode2_child_permission_reply_failure","ordinal":0}}}} +{"type":"emit_inbound","label":"child.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_child_permission_reply_failure","assistantMessageID":"message_opencode2_child_permission_reply_failure","ordinal":0,"delta":"CHILD_OK"}}}} +{"type":"emit_inbound","label":"child.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_child_permission_reply_failure","assistantMessageID":"message_opencode2_child_permission_reply_failure","ordinal":0,"text":"CHILD_OK"}}}} +{"type":"emit_inbound","label":"child.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_child_permission_reply_failure"}}}} +{"type":"emit_inbound","label":"subagent.success","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_permission_reply_failure_subagent","assistantMessageID":"message_opencode2_permission_reply_failure_subagent","ordinal":0,"callID":"call_opencode2_permission_reply_failure_subagent","content":[{"type":"text","text":"CHILD_OK"}],"structured":{"sessionID":"ses_opencode2_child_permission_reply_failure"}}}}} +{"type":"emit_inbound","label":"root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_permission_reply_failure_subagent","assistantMessageID":"message_opencode2_parent_permission_reply_failure_subagent","ordinal":1}}}} +{"type":"emit_inbound","label":"root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_permission_reply_failure_subagent","assistantMessageID":"message_opencode2_parent_permission_reply_failure_subagent","ordinal":1,"delta":"PARENT_OK"}}}} +{"type":"emit_inbound","label":"root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_permission_reply_failure_subagent","assistantMessageID":"message_opencode2_parent_permission_reply_failure_subagent","ordinal":1,"text":"PARENT_OK"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_permission_reply_failure_subagent"}}}} +{"type":"expect_outbound","label":"root.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_permission_reply_failure_subagent"}}} +{"type":"emit_inbound","label":"root.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-permission-reply-failure-subagent"}}}} +{"type":"emit_inbound","label":"root.shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure_subagent/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure_subagent/output.ts new file mode 100644 index 00000000000..27c90e3fafc --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_reply_failure_subagent/output.ts @@ -0,0 +1,39 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAllRuntimeRequestsResolved, + assertAssistantTextIncludes, + assertBaseProjection, + assertRuntimeRequestCounts, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2PermissionReplyFailureSubagentOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertRuntimeRequestCounts(projection, { total: 1, resolved: 1 }); + assertAllRuntimeRequestsResolved(projection); + + const item = projection.turnItems.find((candidate) => candidate.type === "subagent"); + assert.strictEqual(item?.type, "subagent"); + if (item?.type !== "subagent") throw new Error("OpenCode 2 subagent item is missing"); + assert.strictEqual(item.status, "completed"); + assert.isNotNull(item.childThreadId); + + const child = result.projections.get(item.childThreadId!); + assert.isDefined(child); + assert.strictEqual(child!.thread.lineage.parentThreadId, projection.thread.id); + assert.strictEqual(child!.thread.lineage.relationshipToParent, "subagent"); + assertRuntimeRequestCounts(child!, { total: 0 }); + assertAssistantTextIncludes(child!, "CHILD_OK"); + assertAssistantTextIncludes(projection, "PARENT_OK"); + assert.strictEqual(projection.subagents[0]?.status, "completed"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_session/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_session/input.ts new file mode 100644 index 00000000000..c0035b32e67 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_session/input.ts @@ -0,0 +1,10 @@ +import { SIMPLE_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2PermissionSessionInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: SIMPLE_PROMPT }, + { type: "approve_next_runtime_request", decision: "acceptForSession" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_session/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_session/opencode2_transcript.ndjson new file mode 100644 index 00000000000..866c2f7a5ca --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_session/opencode2_transcript.ndjson @@ -0,0 +1,26 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16383","scenario":"opencode2_permission_session","metadata":{"source":"derived-from-live-run","capturedAt":"2026-07-29","nativeSessionId":"ses_opencode2_permission_session","model":"opencode/big-pickle","description":"An interactive permission accepted for the T3 provider session, followed by a matching request that is answered locally without persisting an OpenCode project-wide grant."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_permission_session","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785297600000,"updated":1785297600000},"title":"T3 OpenCode 2 permission replay","location":{"directory":"/private/tmp/t3-opencode2-replay"}}}} +{"type":"expect_outbound","label":"session.prompt","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_permission_session","text":"Respond with the following text: fixture simple ok"}}} +{"type":"emit_inbound","label":"session.prompt.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_permission_session"}}} +{"type":"emit_inbound","label":"session.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_permission_session","inputID":"input_opencode2_permission_session","input":{"id":"input_opencode2_permission_session","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_permission_session"}}}} +{"type":"emit_inbound","label":"permission.v2.asked.first","frame":{"type":"sdk.event","event":{"type":"permission.v2.asked","data":{"id":"permission_opencode2_session_first","sessionID":"ses_opencode2_permission_session","action":"bash","resources":["command-one"],"save":["*"]}}}} +{"type":"expect_outbound","label":"session.permission.reply.first","frame":{"type":"session.permission.reply","input":{"sessionID":"ses_opencode2_permission_session","requestID":"permission_opencode2_session_first","reply":"once"}}} +{"type":"emit_inbound","label":"session.permission.reply.first.response","frame":{"type":"sdk.response","operation":"session.permission.reply","data":null}} +{"type":"emit_inbound","label":"permission.v2.replied.first","frame":{"type":"sdk.event","event":{"type":"permission.v2.replied","data":{"sessionID":"ses_opencode2_permission_session","requestID":"permission_opencode2_session_first","reply":"once"}}}} +{"type":"emit_inbound","label":"permission.v2.asked.second","frame":{"type":"sdk.event","event":{"type":"permission.v2.asked","data":{"id":"permission_opencode2_session_second","sessionID":"ses_opencode2_permission_session","action":"bash","resources":["command-two"],"save":["*"]}}}} +{"type":"expect_outbound","label":"session.permission.reply.second","frame":{"type":"session.permission.reply","input":{"sessionID":"ses_opencode2_permission_session","requestID":"permission_opencode2_session_second","reply":"once"}}} +{"type":"emit_inbound","label":"session.permission.reply.second.response","frame":{"type":"sdk.response","operation":"session.permission.reply","data":null}} +{"type":"emit_inbound","label":"session.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_permission_session","assistantMessageID":"message_opencode2_permission_session","ordinal":0}}}} +{"type":"emit_inbound","label":"session.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_permission_session","assistantMessageID":"message_opencode2_permission_session","ordinal":0,"delta":"fixture simple ok"}}}} +{"type":"emit_inbound","label":"session.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_permission_session","assistantMessageID":"message_opencode2_permission_session","ordinal":0,"text":"fixture simple ok"}}}} +{"type":"emit_inbound","label":"session.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_permission_session"}}}} +{"type":"expect_outbound","label":"session.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_permission_session"}}} +{"type":"emit_inbound","label":"session.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-replay"}}}} +{"type":"emit_inbound","label":"shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_session/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_session/output.ts new file mode 100644 index 00000000000..5719bf00e70 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_permission_session/output.ts @@ -0,0 +1,24 @@ +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAllRuntimeRequestsResolved, + assertAssistantTextIncludes, + assertBaseProjection, + assertRuntimeRequestCounts, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2PermissionSessionOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertRuntimeRequestCounts(projection, { total: 1, resolved: 1 }); + assertAllRuntimeRequestsResolved(projection); + assertAssistantTextIncludes(projection, "fixture simple ok"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question_legacy/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question_legacy/input.ts new file mode 100644 index 00000000000..68c1b8e0ef6 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question_legacy/input.ts @@ -0,0 +1,16 @@ +import { PLAN_QUESTIONS_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2QuestionLegacyInput(): OrchestratorFixtureInput { + return { + interactionMode: "plan", + steps: [ + { type: "message", text: PLAN_QUESTIONS_PROMPT }, + { + type: "answer_next_user_input_request", + answers: { + "question-0-schema-vs-flexibility": "Strict schemas", + }, + }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question_legacy/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question_legacy/opencode2_transcript.ndjson new file mode 100644 index 00000000000..de98b995a40 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question_legacy/opencode2_transcript.ndjson @@ -0,0 +1,23 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16558","scenario":"opencode2_question_legacy","metadata":{"source":"replay","capturedAt":"2026-07-30","nativeSessionId":"ses_opencode2_question_legacy","model":"opencode/big-pickle","description":"A legacy-named question.asked event is answered through the OpenCode 2 session question reply route."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"plan","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_question_legacy","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785457425000,"updated":1785457425000},"title":"T3 OpenCode 2 legacy question replay","location":{"directory":"/private/tmp/t3-opencode2-question-legacy"}}}} +{"type":"expect_outbound","label":"session.prompt","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_question_legacy","text":"Use request_user_input to ask one multiple-choice clarifying question about whether this fixture should prefer strict schemas or UI flexibility. After receiving the answer, respond exactly: plan questions fixture complete"}}} +{"type":"emit_inbound","label":"session.prompt.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_question_legacy"}}} +{"type":"emit_inbound","label":"session.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_question_legacy","inputID":"input_opencode2_question_legacy","input":{"id":"input_opencode2_question_legacy","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_question_legacy"}}}} +{"type":"emit_inbound","label":"question.asked","frame":{"type":"sdk.event","event":{"id":"event_opencode2_question_legacy","created":1785457425100,"type":"question.asked","data":{"id":"question_opencode2_question_legacy","sessionID":"ses_opencode2_question_legacy","questions":[{"question":"Should this fixture prefer strict schemas or UI flexibility?","header":"Schema vs Flexibility","options":[{"label":"Strict schemas","description":"Prioritize validation and exact structure over visual freedom."},{"label":"UI flexibility","description":"Prioritize adaptable input handling over a fixed answer."}],"multiple":false}],"tool":{"messageID":"message_opencode2_question_legacy","callID":"call_opencode2_question_legacy"}}}}} +{"type":"expect_outbound","label":"session.question.reply","frame":{"type":"session.question.reply","input":{"sessionID":"ses_opencode2_question_legacy","requestID":"question_opencode2_question_legacy","questionV2Reply":{"answers":[["Strict schemas"]]}}}} +{"type":"emit_inbound","label":"session.question.reply.response","frame":{"type":"sdk.response","operation":"session.question.reply","data":null}} +{"type":"emit_inbound","label":"question.replied","frame":{"type":"sdk.event","event":{"id":"event_opencode2_question_legacy_replied","created":1785457425200,"type":"question.replied","data":{"sessionID":"ses_opencode2_question_legacy","requestID":"question_opencode2_question_legacy","answers":[["Strict schemas"]]}}}} +{"type":"emit_inbound","label":"session.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_question_legacy","assistantMessageID":"message_opencode2_question_legacy","ordinal":0}}}} +{"type":"emit_inbound","label":"session.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_question_legacy","assistantMessageID":"message_opencode2_question_legacy","ordinal":0,"delta":"plan questions fixture complete"}}}} +{"type":"emit_inbound","label":"session.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_question_legacy","assistantMessageID":"message_opencode2_question_legacy","ordinal":0,"text":"plan questions fixture complete"}}}} +{"type":"emit_inbound","label":"session.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_question_legacy"}}}} +{"type":"expect_outbound","label":"session.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_question_legacy"}}} +{"type":"emit_inbound","label":"session.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-question-legacy"}}}} +{"type":"emit_inbound","label":"shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question_legacy/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question_legacy/output.ts new file mode 100644 index 00000000000..d38364d04bf --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_question_legacy/output.ts @@ -0,0 +1,32 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAllRuntimeRequestsResolved, + assertAssistantTextIncludes, + assertBaseProjection, + assertRuntimeRequestCounts, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2QuestionLegacyOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertRuntimeRequestCounts(projection, { total: 1, resolved: 1 }); + assertAllRuntimeRequestsResolved(projection); + const requestItem = projection.turnItems.find((item) => item.type === "user_input_request"); + assert.strictEqual(requestItem?.type, "user_input_request"); + if (requestItem?.type !== "user_input_request") { + throw new Error("OpenCode 2 question request item is missing"); + } + assert.strictEqual(requestItem.questions[0]?.id, "question-0-schema-vs-flexibility"); + assert.strictEqual(requestItem.status, "completed"); + assertAssistantTextIncludes(projection, "plan questions fixture complete"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retired_suppress_wake/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retired_suppress_wake/input.ts new file mode 100644 index 00000000000..b05d26f9fab --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retired_suppress_wake/input.ts @@ -0,0 +1,11 @@ +import { OPENCODE2_SUBAGENT_BACKGROUND_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2RetiredSuppressWakeInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_SUBAGENT_BACKGROUND_PROMPT }, + { type: "message", text: "Recover after retirement. Respond exactly RECOVERY_ONE" }, + { type: "message", text: "Recover after retired cancellation. Respond exactly RECOVERY_TWO" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retired_suppress_wake/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retired_suppress_wake/opencode2_transcript.ndjson new file mode 100644 index 00000000000..7f8223baf5d --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retired_suppress_wake/opencode2_transcript.ndjson @@ -0,0 +1,52 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16540","scenario":"opencode2_retired_suppress_wake","metadata":{"source":"focused-provider-native-replay-ownership","description":"A never-promoted cancelled wake is retired out of ordinary fallback evidence while a second retired cancelled wake starts execution before its late promotion. The late promotion must still suppress that execution, and recovery must remain clean."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_retired_suppress_wake","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785394800000,"updated":1785394800000},"title":"T3 OpenCode 2 retired suppress wake","location":{"directory":"/private/tmp/t3-opencode2-retired-suppress-wake"}}}} +{"type":"expect_outbound","label":"session.prompt.root","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_retired_suppress_wake","text":"Start one background subagent with description background child fixture and prompt Respond exactly CHILD_BACKGROUND_OK. Then respond exactly PARENT_RELEASED without waiting for the child."}}} +{"type":"emit_inbound","label":"session.prompt.root.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_retired_suppress_wake_root"}}} +{"type":"emit_inbound","label":"root.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_retired_suppress_wake","inputID":"input_opencode2_retired_suppress_wake_root","input":{"type":"user","data":{"text":"Start one background subagent"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"root.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_retired_suppress_wake","inputID":"input_opencode2_retired_suppress_wake_root"}}}} +{"type":"emit_inbound","label":"root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_retired_suppress_wake"}}}} +{"type":"emit_inbound","label":"root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_retired_suppress_wake","assistantMessageID":"message_opencode2_retired_suppress_wake_root","ordinal":0}}}} +{"type":"emit_inbound","label":"root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_retired_suppress_wake","assistantMessageID":"message_opencode2_retired_suppress_wake_root","ordinal":0,"delta":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_retired_suppress_wake","assistantMessageID":"message_opencode2_retired_suppress_wake_root","ordinal":0,"text":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_retired_suppress_wake"}}}} +{"type":"expect_outbound","label":"root.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_retired_suppress_wake"}}} +{"type":"emit_inbound","label":"root.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-retired-suppress-wake"}}}} +{"type":"emit_inbound","label":"root.shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"emit_inbound","label":"retired.alpha.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_retired_suppress_wake","inputID":"input_opencode2_retired_suppress_wake_alpha","input":{"type":"synthetic","data":{"text":"ALPHA_CANCELLED","description":"alpha cancelled child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"retired.bravo.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_retired_suppress_wake","inputID":"input_opencode2_retired_suppress_wake_bravo","input":{"type":"synthetic","data":{"text":"BRAVO_CANCELLED","description":"bravo cancelled child fixture"},"delivery":"queue"}}}}} +{"type":"expect_outbound","label":"session.prompt.recovery.one","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_retired_suppress_wake","text":"Recover after retirement. Respond exactly RECOVERY_ONE"}}} +{"type":"emit_inbound","label":"session.prompt.recovery.one.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_retired_suppress_wake_recovery_one"}}} +{"type":"emit_inbound","label":"recovery.one.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_retired_suppress_wake","inputID":"input_opencode2_retired_suppress_wake_recovery_one","input":{"type":"user","data":{"text":"Recover after retirement. Respond exactly RECOVERY_ONE"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"recovery.one.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_retired_suppress_wake"}}}} +{"type":"emit_inbound","label":"recovery.one.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_retired_suppress_wake","assistantMessageID":"message_opencode2_retired_suppress_wake_recovery_one","ordinal":0}}}} +{"type":"emit_inbound","label":"recovery.one.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_retired_suppress_wake","assistantMessageID":"message_opencode2_retired_suppress_wake_recovery_one","ordinal":0,"delta":"RECOVERY_ONE"}}}} +{"type":"emit_inbound","label":"recovery.one.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_retired_suppress_wake","assistantMessageID":"message_opencode2_retired_suppress_wake_recovery_one","ordinal":0,"text":"RECOVERY_ONE"}}}} +{"type":"emit_inbound","label":"recovery.one.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_retired_suppress_wake"}}}} +{"type":"expect_outbound","label":"recovery.one.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_retired_suppress_wake"}}} +{"type":"emit_inbound","label":"recovery.one.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"recovery.one.shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-retired-suppress-wake"}}}} +{"type":"emit_inbound","label":"recovery.one.shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"emit_inbound","label":"retired.bravo.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_retired_suppress_wake"}}}} +{"type":"emit_inbound","label":"retired.bravo.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_retired_suppress_wake","inputID":"input_opencode2_retired_suppress_wake_bravo"}}}} +{"type":"emit_inbound","label":"retired.bravo.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_retired_suppress_wake","assistantMessageID":"message_opencode2_retired_suppress_wake_bravo","ordinal":0}}}} +{"type":"emit_inbound","label":"retired.bravo.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_retired_suppress_wake","assistantMessageID":"message_opencode2_retired_suppress_wake_bravo","ordinal":0,"delta":"CANCELLED_OUTPUT_MUST_NOT_APPEAR"}}}} +{"type":"emit_inbound","label":"retired.bravo.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_retired_suppress_wake","assistantMessageID":"message_opencode2_retired_suppress_wake_bravo","ordinal":0,"text":"CANCELLED_OUTPUT_MUST_NOT_APPEAR"}}}} +{"type":"emit_inbound","label":"retired.bravo.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_retired_suppress_wake"}}}} +{"type":"expect_outbound","label":"session.prompt.recovery.two","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_retired_suppress_wake","text":"Recover after retired cancellation. Respond exactly RECOVERY_TWO"}}} +{"type":"emit_inbound","label":"session.prompt.recovery.two.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{}}} +{"type":"emit_inbound","label":"recovery.two.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_retired_suppress_wake"}}}} +{"type":"emit_inbound","label":"recovery.two.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_retired_suppress_wake","inputID":"input_opencode2_retired_suppress_wake_recovery_two","input":{"type":"user","data":{"text":"Recover after retired cancellation. Respond exactly RECOVERY_TWO"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"recovery.two.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_retired_suppress_wake","assistantMessageID":"message_opencode2_retired_suppress_wake_recovery_two","ordinal":0}}}} +{"type":"emit_inbound","label":"recovery.two.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_retired_suppress_wake","assistantMessageID":"message_opencode2_retired_suppress_wake_recovery_two","ordinal":0,"delta":"RECOVERY_TWO"}}}} +{"type":"emit_inbound","label":"recovery.two.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_retired_suppress_wake","assistantMessageID":"message_opencode2_retired_suppress_wake_recovery_two","ordinal":0,"text":"RECOVERY_TWO"}}}} +{"type":"emit_inbound","label":"recovery.two.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_retired_suppress_wake"}}}} +{"type":"expect_outbound","label":"recovery.two.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_retired_suppress_wake"}}} +{"type":"emit_inbound","label":"recovery.two.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"recovery.two.shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-retired-suppress-wake"}}}} +{"type":"emit_inbound","label":"recovery.two.shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retired_suppress_wake/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retired_suppress_wake/output.ts new file mode 100644 index 00000000000..2c8ee6abc9a --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retired_suppress_wake/output.ts @@ -0,0 +1,54 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2RetiredSuppressWakeOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + const projection = projectionFor(result, transcript.scenario); + const recoveryOneStarted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "recovery.one.execution.started", + ); + const wakeBPromoted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "retired.bravo.input.promoted", + ); + const wakeBStarted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "retired.bravo.execution.started", + ); + const recoveryTwoStarted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "recovery.two.execution.started", + ); + const recoveryTwoAdmitted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "recovery.two.input.admitted", + ); + assert.isAtLeast(recoveryOneStarted, 0); + assert.isAtLeast(wakeBPromoted, 0); + assert.isAtLeast(wakeBStarted, 0); + assert.isAtLeast(recoveryTwoStarted, 0); + assert.isAtLeast(recoveryTwoAdmitted, 0); + assert.isAbove(wakeBStarted, recoveryOneStarted); + assert.isAbove(wakeBPromoted, wakeBStarted); + assert.isAbove(recoveryTwoStarted, wakeBPromoted); + assert.isAbove(recoveryTwoAdmitted, recoveryTwoStarted); + + assertBaseProjection({ + result, + transcript, + runCount: 3, + runStatuses: ["completed", "completed", "completed"], + }); + assertSemanticProjectionIntegrity(projection); + assertAssistantTextIncludes(projection, "PARENT_RELEASED"); + assertAssistantTextIncludes(projection, "RECOVERY_ONE"); + assertAssistantTextIncludes(projection, "RECOVERY_TWO"); + assert.notInclude(JSON.stringify(projection), "ALPHA_CANCELLED"); + assert.notInclude(JSON.stringify(projection), "CANCELLED_OUTPUT_MUST_NOT_APPEAR"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retry/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retry/input.ts new file mode 100644 index 00000000000..82f16bf9cc2 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retry/input.ts @@ -0,0 +1,7 @@ +import { OPENCODE2_RETRY_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2RetryInput(): OrchestratorFixtureInput { + return { + steps: [{ type: "message", text: OPENCODE2_RETRY_PROMPT }], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retry/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retry/opencode2_transcript.ndjson new file mode 100644 index 00000000000..160e101d626 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retry/opencode2_transcript.ndjson @@ -0,0 +1,20 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16383","scenario":"opencode2_retry","metadata":{"source":"sdk-contract-replay","capturedAt":"2026-07-29","nativeSessionId":"ses_opencode2_retry","model":"opencode/big-pickle","description":"A transient model failure schedules a provider-managed retry within the same execution. T3 must keep one active run and project only the eventual assistant response."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_retry","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785297600000,"updated":1785297600000},"title":"T3 OpenCode 2 retry replay","location":{"directory":"/private/tmp/t3-opencode2-retry-replay"}}}} +{"type":"expect_outbound","label":"session.prompt","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_retry","text":"Recover from the transient provider error, then respond exactly: retry fixture complete"}}} +{"type":"emit_inbound","label":"session.prompt.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_retry"}}} +{"type":"emit_inbound","label":"session.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_retry","inputID":"input_opencode2_retry","input":{"id":"input_opencode2_retry","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_retry"}}}} +{"type":"emit_inbound","label":"session.retry.scheduled","frame":{"type":"sdk.event","event":{"id":"event_opencode2_retry_scheduled","created":1785297600100,"type":"session.retry.scheduled","data":{"sessionID":"ses_opencode2_retry","assistantMessageID":"message_opencode2_retry","attempt":1,"at":1785297601100,"error":{"type":"ProviderError","message":"Transient upstream capacity error"}}}}} +{"type":"emit_inbound","label":"session.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_retry","assistantMessageID":"message_opencode2_retry","ordinal":0}}}} +{"type":"emit_inbound","label":"session.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_retry","assistantMessageID":"message_opencode2_retry","ordinal":0,"delta":"retry fixture complete"}}}} +{"type":"emit_inbound","label":"session.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_retry","assistantMessageID":"message_opencode2_retry","ordinal":0,"text":"retry fixture complete"}}}} +{"type":"emit_inbound","label":"session.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_retry"}}}} +{"type":"expect_outbound","label":"session.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_retry"}}} +{"type":"emit_inbound","label":"session.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-retry-replay"}}}} +{"type":"emit_inbound","label":"shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retry/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retry/output.ts new file mode 100644 index 00000000000..be53e0b0765 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_retry/output.ts @@ -0,0 +1,39 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertConversationMessageRoles, + assertSemanticProjectionIntegrity, + assertTurnItemTypes, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_RETRY_PROMPT, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2RetryOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertConversationMessageRoles(projection, ["user", "assistant"]); + assertTurnItemTypes(projection, ["user_message", "assistant_message"]); + assertUserMessagesInclude(projection, [OPENCODE2_RETRY_PROMPT]); + assertAssistantTextIncludes(projection, "retry fixture complete"); + + const scheduled = transcript.entries.filter( + (entry) => entry.type === "emit_inbound" && entry.label === "session.retry.scheduled", + ); + assert.equal(scheduled.length, 1); + assert.isFalse( + projection.turnItems.some((item) => item.type === "error"), + "a provider-managed retry must not become a failed T3 turn item", + ); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_execution_replay/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_execution_replay/input.ts new file mode 100644 index 00000000000..42451ef15e9 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_execution_replay/input.ts @@ -0,0 +1,14 @@ +import { + OPENCODE2_TWO_COMPLETED_SUBAGENT_PROMPT, + type OrchestratorFixtureInput, +} from "../shared.ts"; + +export function openCode2SharedExecutionReplayInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_TWO_COMPLETED_SUBAGENT_PROMPT }, + { type: "provider_continuation" }, + { type: "provider_continuation" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_execution_replay/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_execution_replay/opencode2_transcript.ndjson new file mode 100644 index 00000000000..3792c42af9c --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_execution_replay/opencode2_transcript.ndjson @@ -0,0 +1,37 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16540","scenario":"opencode2_shared_execution_replay","metadata":{"source":"focused-provider-native-replay-ownership","description":"Two completed-child synthetic inputs are promoted into one OpenCode 2 execution. The execution output may be replayed by at most one continuation."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_shared_execution_replay","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785394800000,"updated":1785394800000},"title":"T3 OpenCode 2 shared execution replay","location":{"directory":"/private/tmp/t3-opencode2-shared-execution-replay"}}}} +{"type":"expect_outbound","label":"session.prompt.root","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_shared_execution_replay","text":"Start two background subagents: one with description alpha completed child fixture and prompt Respond exactly ALPHA_COMPLETED_OK, and one with description bravo completed child fixture and prompt Respond exactly BRAVO_COMPLETED_OK. Then respond exactly PARENT_RELEASED without waiting for either child."}}} +{"type":"emit_inbound","label":"session.prompt.root.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_shared_execution_replay_root"}}} +{"type":"emit_inbound","label":"root.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_shared_execution_replay","inputID":"input_opencode2_shared_execution_replay_root","input":{"type":"user","data":{"text":"Start two background subagents"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"root.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_shared_execution_replay","inputID":"input_opencode2_shared_execution_replay_root"}}}} +{"type":"emit_inbound","label":"root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_shared_execution_replay"}}}} +{"type":"emit_inbound","label":"root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_shared_execution_replay","assistantMessageID":"message_opencode2_shared_execution_replay_root","ordinal":0}}}} +{"type":"emit_inbound","label":"root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_shared_execution_replay","assistantMessageID":"message_opencode2_shared_execution_replay_root","ordinal":0,"delta":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_shared_execution_replay","assistantMessageID":"message_opencode2_shared_execution_replay_root","ordinal":0,"text":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_shared_execution_replay"}}}} +{"type":"expect_outbound","label":"root.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_shared_execution_replay"}}} +{"type":"emit_inbound","label":"root.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-shared-execution-replay"}}}} +{"type":"emit_inbound","label":"root.shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"emit_inbound","label":"wake.alpha.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_shared_execution_replay","inputID":"input_opencode2_shared_execution_replay_alpha","input":{"type":"synthetic","data":{"text":"ALPHA_COMPLETED_OK","description":"alpha completed child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"wake.bravo.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_shared_execution_replay","inputID":"input_opencode2_shared_execution_replay_bravo","input":{"type":"synthetic","data":{"text":"BRAVO_COMPLETED_OK","description":"bravo completed child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"wake.alpha.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_shared_execution_replay","inputID":"input_opencode2_shared_execution_replay_alpha"}}}} +{"type":"emit_inbound","label":"wake.bravo.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_shared_execution_replay","inputID":"input_opencode2_shared_execution_replay_bravo"}}}} +{"type":"emit_inbound","label":"shared.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_shared_execution_replay"}}}} +{"type":"emit_inbound","label":"shared.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_shared_execution_replay","assistantMessageID":"message_opencode2_shared_execution_replay_shared","ordinal":0}}}} +{"type":"emit_inbound","label":"shared.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_shared_execution_replay","assistantMessageID":"message_opencode2_shared_execution_replay_shared","ordinal":0,"delta":"SHARED_EXECUTION_OUTPUT"}}}} +{"type":"emit_inbound","label":"shared.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_shared_execution_replay","assistantMessageID":"message_opencode2_shared_execution_replay_shared","ordinal":0,"text":"SHARED_EXECUTION_OUTPUT"}}}} +{"type":"emit_inbound","label":"shared.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_shared_execution_replay"}}}} +{"type":"expect_outbound","label":"first.continuation.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_shared_execution_replay"}}} +{"type":"emit_inbound","label":"first.continuation.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"first.continuation.shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-shared-execution-replay"}}}} +{"type":"emit_inbound","label":"first.continuation.shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"expect_outbound","label":"second.continuation.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_shared_execution_replay"}}} +{"type":"emit_inbound","label":"second.continuation.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"second.continuation.shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-shared-execution-replay"}}}} +{"type":"emit_inbound","label":"second.continuation.shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_execution_replay/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_execution_replay/output.ts new file mode 100644 index 00000000000..ec927382290 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_execution_replay/output.ts @@ -0,0 +1,38 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +const SHARED_OUTPUT = "SHARED_EXECUTION_OUTPUT"; + +export function assertOpenCode2SharedExecutionReplayOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + const projection = projectionFor(result, transcript.scenario); + assertBaseProjection({ + result, + transcript, + runCount: 3, + runStatuses: ["completed", "completed", "completed"], + }); + assertSemanticProjectionIntegrity(projection); + assertAssistantTextIncludes(projection, SHARED_OUTPUT); + + const sharedItems = projection.turnItems.filter( + (item) => item.type === "assistant_message" && item.text.includes(SHARED_OUTPUT), + ); + assert.lengthOf(sharedItems, 1); + assert.equal(sharedItems[0]?.runId, projection.runs[1]?.id); + assert.isFalse( + projection.turnItems.some( + (item) => item.runId === projection.runs[2]?.id && item.type === "assistant_message", + ), + ); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_ordinary_wake_replay/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_ordinary_wake_replay/input.ts new file mode 100644 index 00000000000..2f4bc9fbca1 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_ordinary_wake_replay/input.ts @@ -0,0 +1,10 @@ +import { OPENCODE2_SUBAGENT_BACKGROUND_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2SharedOrdinaryWakeReplayInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_SUBAGENT_BACKGROUND_PROMPT }, + { type: "provider_continuation" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_ordinary_wake_replay/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_ordinary_wake_replay/opencode2_transcript.ndjson new file mode 100644 index 00000000000..2be80494de7 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_ordinary_wake_replay/opencode2_transcript.ndjson @@ -0,0 +1,26 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16540","scenario":"opencode2_shared_ordinary_wake_replay","metadata":{"source":"focused-provider-native-replay-ownership","description":"An ordinary root input is promoted before execution while one completed-child synthetic input promotes late into the same OpenCode 2 execution. The ordinary turn must retain the shared native output without duplicate or re-parented replay output."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_shared_ordinary_wake_replay","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785394800000,"updated":1785394800000},"title":"T3 OpenCode 2 shared ordinary wake replay","location":{"directory":"/private/tmp/t3-opencode2-shared-ordinary-wake-replay"}}}} +{"type":"expect_outbound","label":"session.prompt.root","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_shared_ordinary_wake_replay","text":"Start one background subagent with description background child fixture and prompt Respond exactly CHILD_BACKGROUND_OK. Then respond exactly PARENT_RELEASED without waiting for the child."}}} +{"type":"emit_inbound","label":"session.prompt.root.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_shared_ordinary_wake_replay_root"}}} +{"type":"emit_inbound","label":"root.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_shared_ordinary_wake_replay","inputID":"input_opencode2_shared_ordinary_wake_replay_root","input":{"type":"user","data":{"text":"Start one background subagent"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"root.input.promoted.early","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_shared_ordinary_wake_replay","inputID":"input_opencode2_shared_ordinary_wake_replay_root"}}}} +{"type":"emit_inbound","label":"wake.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_shared_ordinary_wake_replay","inputID":"input_opencode2_shared_ordinary_wake_replay_wake","input":{"type":"synthetic","data":{"text":"SHARED_ORDINARY_WAKE_OUTPUT","description":"background child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"shared.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_shared_ordinary_wake_replay"}}}} +{"type":"emit_inbound","label":"wake.input.promoted.late","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_shared_ordinary_wake_replay","inputID":"input_opencode2_shared_ordinary_wake_replay_wake"}}}} +{"type":"emit_inbound","label":"shared.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_shared_ordinary_wake_replay","assistantMessageID":"message_opencode2_shared_ordinary_wake_replay_shared","ordinal":0}}}} +{"type":"emit_inbound","label":"shared.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_shared_ordinary_wake_replay","assistantMessageID":"message_opencode2_shared_ordinary_wake_replay_shared","ordinal":0,"delta":"SHARED_ORDINARY_WAKE_OUTPUT"}}}} +{"type":"emit_inbound","label":"shared.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_shared_ordinary_wake_replay","assistantMessageID":"message_opencode2_shared_ordinary_wake_replay_shared","ordinal":0,"text":"SHARED_ORDINARY_WAKE_OUTPUT"}}}} +{"type":"emit_inbound","label":"shared.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_shared_ordinary_wake_replay"}}}} +{"type":"expect_outbound","label":"root.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_shared_ordinary_wake_replay"}}} +{"type":"emit_inbound","label":"root.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-shared-ordinary-wake-replay"}}}} +{"type":"emit_inbound","label":"root.shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"expect_outbound","label":"continuation.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_shared_ordinary_wake_replay"}}} +{"type":"emit_inbound","label":"continuation.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"continuation.shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-shared-ordinary-wake-replay"}}}} +{"type":"emit_inbound","label":"continuation.shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_ordinary_wake_replay/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_ordinary_wake_replay/output.ts new file mode 100644 index 00000000000..af28f559fe1 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shared_ordinary_wake_replay/output.ts @@ -0,0 +1,48 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +const SHARED_OUTPUT = "SHARED_ORDINARY_WAKE_OUTPUT"; + +export function assertOpenCode2SharedOrdinaryWakeReplayOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + const executionStarted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "shared.execution.started", + ); + const rootPromoted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "root.input.promoted.early", + ); + const wakePromoted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "wake.input.promoted.late", + ); + assert.isAtLeast(rootPromoted, 0); + assert.isAtLeast(executionStarted, 0); + assert.isAtLeast(wakePromoted, 0); + assert.isAbove(executionStarted, rootPromoted); + assert.isAbove(wakePromoted, executionStarted); + + const projection = projectionFor(result, transcript.scenario); + assertBaseProjection({ + result, + transcript, + runCount: 2, + runStatuses: ["completed", "completed"], + }); + assertSemanticProjectionIntegrity(projection); + assertAssistantTextIncludes(projection, SHARED_OUTPUT); + + const sharedItems = projection.turnItems.filter( + (item) => item.type === "assistant_message" && item.text.includes(SHARED_OUTPUT), + ); + assert.lengthOf(sharedItems, 1); + assert.equal(sharedItems[0]?.runId, projection.runs[0]?.id); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_projection/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_projection/input.ts new file mode 100644 index 00000000000..9a2df8f4d7d --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_projection/input.ts @@ -0,0 +1,7 @@ +import { OPENCODE2_SHELL_PROJECTION_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2ShellProjectionInput(): OrchestratorFixtureInput { + return { + steps: [{ type: "message", text: OPENCODE2_SHELL_PROJECTION_PROMPT }], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_projection/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_projection/opencode2_transcript.ndjson new file mode 100644 index 00000000000..7ec05f3762e --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_projection/opencode2_transcript.ndjson @@ -0,0 +1,28 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16383","scenario":"opencode2_shell_projection","metadata":{"source":"derived-from-live-run","capturedAt":"2026-07-29","nativeSessionId":"ses_opencode2_shell_projection","model":"opencode/big-pickle","description":"A model shell becomes provider-native background work. Tool and shell events must share one command row, settlement stays pinned while the shell runs, and terminal output is read across two byte-cursor pages."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_shell_projection","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785297600000,"updated":1785297600000},"title":"T3 OpenCode 2 shell replay","location":{"directory":"/private/tmp/t3-opencode2-shell-replay"}}}} +{"type":"expect_outbound","label":"session.prompt","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_shell_projection","text":"Run a shell command that prints the paged shell fixture output, move it to background observation, then respond exactly: shell projection fixture complete"}}} +{"type":"emit_inbound","label":"session.prompt.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_shell_projection"}}} +{"type":"emit_inbound","label":"session.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_shell_projection","inputID":"input_opencode2_shell_projection","input":{"id":"input_opencode2_shell_projection","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_shell_projection"}}}} +{"type":"emit_inbound","label":"session.tool.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_shell_projection","assistantMessageID":"message_opencode2_shell_projection","ordinal":0,"callID":"call_opencode2_shell_projection","name":"bash"}}}} +{"type":"emit_inbound","label":"session.tool.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_shell_projection","assistantMessageID":"message_opencode2_shell_projection","ordinal":0,"callID":"call_opencode2_shell_projection","input":{"command":"printf 'shell page one shell page two'"}}}}} +{"type":"emit_inbound","label":"shell.created","frame":{"type":"sdk.event","event":{"type":"shell.created","data":{"info":{"id":"shell_opencode2_projection","status":"running","command":"printf 'shell page one shell page two'","cwd":"/private/tmp/t3-opencode2-shell-replay","shell":"/bin/bash","file":"/private/tmp/t3-opencode2-shell-replay/shell.log","pid":4242,"metadata":{"sessionID":"ses_opencode2_shell_projection"},"time":{"started":1785297600100}}}}}} +{"type":"emit_inbound","label":"session.tool.progress","frame":{"type":"sdk.event","event":{"type":"session.tool.progress","data":{"sessionID":"ses_opencode2_shell_projection","assistantMessageID":"message_opencode2_shell_projection","ordinal":0,"callID":"call_opencode2_shell_projection","content":[{"type":"text","text":"Shell is still running."}],"structured":{}}}}} +{"type":"emit_inbound","label":"session.tool.success.backgrounded","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_shell_projection","assistantMessageID":"message_opencode2_shell_projection","ordinal":0,"callID":"call_opencode2_shell_projection","content":[{"type":"text","text":"Shell moved to background observation."}],"structured":{}}}}} +{"type":"emit_inbound","label":"session.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_shell_projection","assistantMessageID":"message_opencode2_shell_projection","ordinal":1}}}} +{"type":"emit_inbound","label":"session.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_shell_projection","assistantMessageID":"message_opencode2_shell_projection","ordinal":1,"text":"shell projection fixture complete"}}}} +{"type":"emit_inbound","label":"session.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_shell_projection"}}}} +{"type":"emit_inbound","label":"shell.exited","frame":{"type":"sdk.event","event":{"type":"shell.exited","data":{"id":"shell_opencode2_projection","exit":0,"status":"exited"}}}} +{"type":"expect_outbound","label":"shell.output.first","frame":{"type":"shell.output","input":{"id":"shell_opencode2_projection","location":{"directory":"/private/tmp/t3-opencode2-shell-replay"},"cursor":"0","limit":"65536"}}} +{"type":"emit_inbound","label":"shell.output.first.response","frame":{"type":"sdk.response","operation":"shell.output","data":{"output":"shell page one ","cursor":15,"size":29,"truncated":true}}} +{"type":"expect_outbound","label":"shell.output.second","frame":{"type":"shell.output","input":{"id":"shell_opencode2_projection","location":{"directory":"/private/tmp/t3-opencode2-shell-replay"},"cursor":"15","limit":"65536"}}} +{"type":"emit_inbound","label":"shell.output.second.response","frame":{"type":"sdk.response","operation":"shell.output","data":{"output":"shell page two","cursor":29,"size":29,"truncated":false}}} +{"type":"expect_outbound","label":"session.pending.list.exited","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_shell_projection"}}} +{"type":"emit_inbound","label":"session.pending.list.exited.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list.exited","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-shell-replay"}}}} +{"type":"emit_inbound","label":"shell.list.exited.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_projection/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_projection/output.ts new file mode 100644 index 00000000000..befee1a4c5d --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_projection/output.ts @@ -0,0 +1,37 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertConversationMessageRoles, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_SHELL_PROJECTION_PROMPT, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2ShellProjectionOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertConversationMessageRoles(projection, ["user", "assistant"]); + assertUserMessagesInclude(projection, [OPENCODE2_SHELL_PROJECTION_PROMPT]); + assertAssistantTextIncludes(projection, "shell projection fixture complete"); + + const commands = projection.turnItems.filter((item) => item.type === "command_execution"); + assert.equal(commands.length, 1, "the tool and shell event families must share one command row"); + const command = commands[0]; + assert.isDefined(command); + assert.equal(command.status, "completed"); + assert.include(command.input, "printf"); + assert.equal(command.output, "shell page one shell page two"); + assert.equal(command.exitCode, 0); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_terminals/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_terminals/input.ts new file mode 100644 index 00000000000..9439b08a647 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_terminals/input.ts @@ -0,0 +1,14 @@ +import { + OPENCODE2_SHELL_DELETION_PROMPT, + OPENCODE2_SHELL_FAILURE_PROMPT, + type OrchestratorFixtureInput, +} from "../shared.ts"; + +export function openCode2ShellTerminalsInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_SHELL_FAILURE_PROMPT }, + { type: "message", text: OPENCODE2_SHELL_DELETION_PROMPT }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_terminals/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_terminals/opencode2_transcript.ndjson new file mode 100644 index 00000000000..8e6d81d5790 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_terminals/opencode2_transcript.ndjson @@ -0,0 +1,41 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16383","scenario":"opencode2_shell_terminals","metadata":{"source":"derived-from-live-run","capturedAt":"2026-07-29","nativeSessionId":"ses_opencode2_shell_terminals","model":"opencode/big-pickle","description":"Two post-settle OpenCode 2 shells prove nonzero exit and deletion terminalize their shared command rows without leaving Waiting pinned."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_shell_terminals","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785297600000,"updated":1785297600000},"title":"T3 OpenCode 2 shell terminal replay","location":{"directory":"/private/tmp/t3-opencode2-shell-terminals"}}}} +{"type":"expect_outbound","label":"session.prompt.failure","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_shell_terminals","text":"Run a shell command that exits with status 7, move it to background observation, then respond exactly: shell failure fixture complete"}}} +{"type":"emit_inbound","label":"session.prompt.failure.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_shell_failure"}}} +{"type":"emit_inbound","label":"session.input.admitted.failure","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_shell_terminals","inputID":"input_opencode2_shell_failure","input":{"id":"input_opencode2_shell_failure","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started.failure","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_shell_terminals"}}}} +{"type":"emit_inbound","label":"session.tool.input.started.failure","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_shell_terminals","assistantMessageID":"message_opencode2_shell_failure","ordinal":0,"callID":"call_opencode2_shell_failure","name":"bash"}}}} +{"type":"emit_inbound","label":"session.tool.called.failure","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_shell_terminals","assistantMessageID":"message_opencode2_shell_failure","ordinal":0,"callID":"call_opencode2_shell_failure","input":{"command":"printf 'shell failed'; exit 7"}}}}} +{"type":"emit_inbound","label":"shell.created.failure","frame":{"type":"sdk.event","event":{"type":"shell.created","data":{"info":{"id":"shell_opencode2_failure","status":"running","command":"printf 'shell failed'; exit 7","cwd":"/private/tmp/t3-opencode2-shell-terminals","shell":"/bin/bash","file":"/private/tmp/t3-opencode2-shell-terminals/failure.log","pid":4243,"metadata":{"sessionID":"ses_opencode2_shell_terminals"},"time":{"started":1785297600100}}}}}} +{"type":"emit_inbound","label":"session.tool.success.failure-backgrounded","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_shell_terminals","assistantMessageID":"message_opencode2_shell_failure","ordinal":0,"callID":"call_opencode2_shell_failure","content":[{"type":"text","text":"Shell moved to background observation."}],"structured":{}}}}} +{"type":"emit_inbound","label":"session.text.started.failure","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_shell_terminals","assistantMessageID":"message_opencode2_shell_failure","ordinal":1}}}} +{"type":"emit_inbound","label":"session.text.ended.failure","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_shell_terminals","assistantMessageID":"message_opencode2_shell_failure","ordinal":1,"text":"shell failure fixture complete"}}}} +{"type":"emit_inbound","label":"session.execution.succeeded.failure","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_shell_terminals"}}}} +{"type":"emit_inbound","label":"shell.exited.failure","frame":{"type":"sdk.event","event":{"type":"shell.exited","data":{"id":"shell_opencode2_failure","exit":7,"status":"exited"}}}} +{"type":"expect_outbound","label":"shell.output.failure","frame":{"type":"shell.output","input":{"id":"shell_opencode2_failure","location":{"directory":"/private/tmp/t3-opencode2-shell-terminals"},"cursor":"0","limit":"65536"}}} +{"type":"emit_inbound","label":"shell.output.failure.response","frame":{"type":"sdk.response","operation":"shell.output","data":{"output":"shell failed","cursor":12,"size":12,"truncated":false}}} +{"type":"expect_outbound","label":"session.pending.list.failure","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_shell_terminals"}}} +{"type":"emit_inbound","label":"session.pending.list.failure.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list.failure","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-shell-terminals"}}}} +{"type":"emit_inbound","label":"shell.list.failure.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"expect_outbound","label":"session.prompt.deletion","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_shell_terminals","text":"Run a long shell command, move it to background observation, then respond exactly: shell deletion fixture complete"}}} +{"type":"emit_inbound","label":"session.prompt.deletion.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_shell_deletion"}}} +{"type":"emit_inbound","label":"session.input.admitted.deletion","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_shell_terminals","inputID":"input_opencode2_shell_deletion","input":{"id":"input_opencode2_shell_deletion","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started.deletion","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_shell_terminals"}}}} +{"type":"emit_inbound","label":"session.tool.input.started.deletion","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_shell_terminals","assistantMessageID":"message_opencode2_shell_deletion","ordinal":0,"callID":"call_opencode2_shell_deletion","name":"bash"}}}} +{"type":"emit_inbound","label":"session.tool.called.deletion","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_shell_terminals","assistantMessageID":"message_opencode2_shell_deletion","ordinal":0,"callID":"call_opencode2_shell_deletion","input":{"command":"sleep 60"}}}}} +{"type":"emit_inbound","label":"shell.created.deletion","frame":{"type":"sdk.event","event":{"type":"shell.created","data":{"info":{"id":"shell_opencode2_deletion","status":"running","command":"sleep 60","cwd":"/private/tmp/t3-opencode2-shell-terminals","shell":"/bin/bash","file":"/private/tmp/t3-opencode2-shell-terminals/deletion.log","pid":4244,"metadata":{"sessionID":"ses_opencode2_shell_terminals"},"time":{"started":1785297600200}}}}}} +{"type":"emit_inbound","label":"session.tool.success.deletion-backgrounded","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_shell_terminals","assistantMessageID":"message_opencode2_shell_deletion","ordinal":0,"callID":"call_opencode2_shell_deletion","content":[{"type":"text","text":"Shell moved to background observation."}],"structured":{}}}}} +{"type":"emit_inbound","label":"session.text.started.deletion","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_shell_terminals","assistantMessageID":"message_opencode2_shell_deletion","ordinal":1}}}} +{"type":"emit_inbound","label":"session.text.ended.deletion","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_shell_terminals","assistantMessageID":"message_opencode2_shell_deletion","ordinal":1,"text":"shell deletion fixture complete"}}}} +{"type":"emit_inbound","label":"session.execution.succeeded.deletion","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_shell_terminals"}}}} +{"type":"emit_inbound","label":"shell.deleted","frame":{"type":"sdk.event","event":{"type":"shell.deleted","data":{"id":"shell_opencode2_deletion"}}}} +{"type":"expect_outbound","label":"session.pending.list.deletion","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_shell_terminals"}}} +{"type":"emit_inbound","label":"session.pending.list.deletion.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list.deletion","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-shell-terminals"}}}} +{"type":"emit_inbound","label":"shell.list.deletion.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_terminals/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_terminals/output.ts new file mode 100644 index 00000000000..e646bbc39a1 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_shell_terminals/output.ts @@ -0,0 +1,52 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertConversationMessageRoles, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + OPENCODE2_SHELL_DELETION_PROMPT, + OPENCODE2_SHELL_FAILURE_PROMPT, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2ShellTerminalsOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ + result, + transcript, + runCount: 2, + runStatuses: ["completed", "completed"], + }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertConversationMessageRoles(projection, ["user", "assistant", "user", "assistant"]); + assertUserMessagesInclude(projection, [ + OPENCODE2_SHELL_FAILURE_PROMPT, + OPENCODE2_SHELL_DELETION_PROMPT, + ]); + assertAssistantTextIncludes(projection, "shell failure fixture complete"); + assertAssistantTextIncludes(projection, "shell deletion fixture complete"); + + const commands = projection.turnItems.filter((item) => item.type === "command_execution"); + assert.equal(commands.length, 2, "each tool and shell event pair must share one command row"); + + const failed = commands.find((command) => command.input.includes("exit 7")); + assert.isDefined(failed); + assert.equal(failed.status, "failed"); + assert.equal(failed.output, "shell failed"); + assert.equal(failed.exitCode, 7); + + const deleted = commands.find((command) => command.input.includes("sleep 60")); + assert.isDefined(deleted); + assert.equal(deleted.status, "failed"); + assert.isUndefined(deleted.exitCode); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_background_wake/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_background_wake/input.ts new file mode 100644 index 00000000000..7b7ed3551fa --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_background_wake/input.ts @@ -0,0 +1,10 @@ +import { OPENCODE2_SUBAGENT_BACKGROUND_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2SubagentBackgroundWakeInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_SUBAGENT_BACKGROUND_PROMPT }, + { type: "provider_continuation" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_background_wake/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_background_wake/opencode2_transcript.ndjson new file mode 100644 index 00000000000..46a0ade487e --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_background_wake/opencode2_transcript.ndjson @@ -0,0 +1,43 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16540","scenario":"opencode2_subagent_background_wake","metadata":{"source":"derived-from-live-run","capturedAt":"2026-07-29","nativeSessionId":"ses_opencode2_subagent_background_wake","model":"opencode/big-pickle","description":"A background child finishes after the root execution settles. OpenCode admits a synthetic parent input and completes a native wake execution before T3 attaches its provider-buffered continuation run."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_subagent_background_wake","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785394800000,"updated":1785394800000},"title":"T3 OpenCode 2 background wake replay","location":{"directory":"/private/tmp/t3-opencode2-background-wake"}}}} +{"type":"expect_outbound","label":"session.prompt.root","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_subagent_background_wake","text":"Start one background subagent with description background child fixture and prompt Respond exactly CHILD_BACKGROUND_OK. Then respond exactly PARENT_RELEASED without waiting for the child."}}} +{"type":"emit_inbound","label":"session.prompt.root.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_background_wake_root"}}} +{"type":"emit_inbound","label":"root.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_subagent_background_wake","inputID":"input_opencode2_background_wake_root","input":{"type":"user","data":{"text":"Start one background subagent"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"root.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_subagent_background_wake","inputID":"input_opencode2_background_wake_root"}}}} +{"type":"emit_inbound","label":"root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_subagent_background_wake"}}}} +{"type":"emit_inbound","label":"subagent.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_subagent_background_wake","assistantMessageID":"message_opencode2_background_wake_root","ordinal":0,"callID":"call_opencode2_background_wake","name":"subagent"}}}} +{"type":"emit_inbound","label":"subagent.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_subagent_background_wake","assistantMessageID":"message_opencode2_background_wake_root","ordinal":0,"callID":"call_opencode2_background_wake","input":{"agent":"explore","background":true,"description":"background child fixture","prompt":"Respond exactly CHILD_BACKGROUND_OK"}}}}} +{"type":"emit_inbound","label":"subagent.launch.success","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_subagent_background_wake","assistantMessageID":"message_opencode2_background_wake_root","ordinal":0,"callID":"call_opencode2_background_wake","content":[{"type":"text","text":"Background subagent launched"}],"structured":{"sessionID":"ses_opencode2_background_wake_child"}}}}} +{"type":"emit_inbound","label":"child.session.created","frame":{"type":"sdk.event","event":{"id":"event_opencode2_background_wake_child","created":1785394800100,"type":"session.created","data":{"sessionID":"ses_opencode2_background_wake_child","info":{"id":"ses_opencode2_background_wake_child","slug":"child-background-wake","projectID":"global","directory":"/private/tmp/t3-opencode2-background-wake","parentID":"ses_opencode2_subagent_background_wake","title":"background child fixture","agent":"explore","model":{"id":"big-pickle","providerID":"opencode"},"version":"0.0.0-next-16540","time":{"created":1785394800100,"updated":1785394800100}}}}}} +{"type":"emit_inbound","label":"child.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_background_wake_child","inputID":"input_opencode2_background_wake_child","input":{"type":"user","data":{"text":"Respond exactly CHILD_BACKGROUND_OK"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"child.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_background_wake_child","inputID":"input_opencode2_background_wake_child"}}}} +{"type":"emit_inbound","label":"child.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_background_wake_child"}}}} +{"type":"emit_inbound","label":"root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_subagent_background_wake","assistantMessageID":"message_opencode2_background_wake_root","ordinal":1}}}} +{"type":"emit_inbound","label":"root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_subagent_background_wake","assistantMessageID":"message_opencode2_background_wake_root","ordinal":1,"delta":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_subagent_background_wake","assistantMessageID":"message_opencode2_background_wake_root","ordinal":1,"text":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_subagent_background_wake"}}}} +{"type":"emit_inbound","label":"child.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_background_wake_child","assistantMessageID":"message_opencode2_background_wake_child","ordinal":0}}}} +{"type":"emit_inbound","label":"child.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_background_wake_child","assistantMessageID":"message_opencode2_background_wake_child","ordinal":0,"delta":"CHILD_BACKGROUND_OK"}}}} +{"type":"emit_inbound","label":"child.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_background_wake_child","assistantMessageID":"message_opencode2_background_wake_child","ordinal":0,"text":"CHILD_BACKGROUND_OK"}}}} +{"type":"emit_inbound","label":"child.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_background_wake_child"}}}} +{"type":"expect_outbound","label":"root.pending.list.first","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_subagent_background_wake"}}} +{"type":"emit_inbound","label":"root.pending.list.first.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list.first","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-background-wake"}}}} +{"type":"emit_inbound","label":"shell.list.first.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"emit_inbound","label":"cancelled.wake.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_subagent_background_wake","inputID":"input_opencode2_background_wake_cancelled","input":{"type":"synthetic","data":{"text":"CHILD_CANCELLED_SHOULD_NOT_CONTINUE","description":"cancelled background child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"wake.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_subagent_background_wake","inputID":"input_opencode2_background_wake_synthetic","input":{"type":"synthetic","data":{"text":"CHILD_BACKGROUND_OK","description":"background child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"wake.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_subagent_background_wake","inputID":"input_opencode2_background_wake_synthetic"}}}} +{"type":"emit_inbound","label":"wake.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_subagent_background_wake"}}}} +{"type":"emit_inbound","label":"wake.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_subagent_background_wake","assistantMessageID":"message_opencode2_background_wake_synthetic","ordinal":0}}}} +{"type":"emit_inbound","label":"wake.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_subagent_background_wake","assistantMessageID":"message_opencode2_background_wake_synthetic","ordinal":0,"delta":"CHILD_BACKGROUND_OK"}}}} +{"type":"emit_inbound","label":"wake.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_subagent_background_wake","assistantMessageID":"message_opencode2_background_wake_synthetic","ordinal":0,"text":"CHILD_BACKGROUND_OK"}}}} +{"type":"emit_inbound","label":"wake.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_subagent_background_wake"}}}} +{"type":"expect_outbound","label":"root.pending.list.continuation","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_subagent_background_wake"}}} +{"type":"emit_inbound","label":"root.pending.list.continuation.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list.continuation","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-background-wake"}}}} +{"type":"emit_inbound","label":"shell.list.continuation.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_background_wake/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_background_wake/output.ts new file mode 100644 index 00000000000..db84ca11d38 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_background_wake/output.ts @@ -0,0 +1,48 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertRunOrdinals, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2SubagentBackgroundWakeOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ + result, + transcript, + runCount: 2, + runStatuses: ["completed", "completed"], + }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertRunOrdinals(projection, [1, 2]); + assertAssistantTextIncludes(projection, "PARENT_RELEASED"); + assertAssistantTextIncludes(projection, "CHILD_BACKGROUND_OK"); + assert.notInclude(JSON.stringify(projection), "CHILD_CANCELLED_SHOULD_NOT_CONTINUE"); + + const subagentItem = projection.turnItems.find((candidate) => candidate.type === "subagent"); + assert.strictEqual(subagentItem?.type, "subagent"); + if (subagentItem?.type !== "subagent") { + throw new Error("OpenCode 2 background subagent item is missing"); + } + assert.strictEqual(subagentItem.status, "completed"); + assert.isNotNull(subagentItem.childThreadId); + const child = result.projections.get(subagentItem.childThreadId!); + assert.isDefined(child); + assertAssistantTextIncludes(child!, "CHILD_BACKGROUND_OK"); + + const continuation = projection.runs[1]; + assert.isDefined(continuation); + const continuationItems = projection.turnItems.filter( + (item) => item.runId === continuation!.id && item.type === "assistant_message", + ); + assert.lengthOf(continuationItems, 1); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_queued_turn/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_queued_turn/input.ts new file mode 100644 index 00000000000..2ac79304982 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_queued_turn/input.ts @@ -0,0 +1,15 @@ +import { + MULTI_TURN_SECOND_PROMPT, + OPENCODE2_SUBAGENT_BACKGROUND_PROMPT, + type OrchestratorFixtureInput, +} from "../shared.ts"; + +export function openCode2SubagentQueuedTurnInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_SUBAGENT_BACKGROUND_PROMPT }, + { type: "queue_message", text: MULTI_TURN_SECOND_PROMPT }, + { type: "provider_continuation" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_queued_turn/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_queued_turn/opencode2_transcript.ndjson new file mode 100644 index 00000000000..de6e6d526e6 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_queued_turn/opencode2_transcript.ndjson @@ -0,0 +1,54 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16540","scenario":"opencode2_subagent_queued_turn","metadata":{"source":"derived-from-live-run","capturedAt":"2026-07-30","nativeSessionId":"ses_opencode2_subagent_queued_turn","model":"opencode/big-pickle","description":"The parent settles while its native child remains active. T3 promotes one queued user turn after the child terminalizes, buffers the synthetic OpenCode parent wake that races ahead of that user's native execution, and attaches the wake exactly once afterward."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_subagent_queued_turn","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785391200000,"updated":1785391200000},"title":"T3 OpenCode 2 background child queue replay","location":{"directory":"/private/tmp/t3-opencode2-subagent-queue"}}}} +{"type":"expect_outbound","label":"session.prompt.first","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_subagent_queued_turn","text":"Start one background subagent with description background child fixture and prompt Respond exactly CHILD_BACKGROUND_OK. Then respond exactly PARENT_RELEASED without waiting for the child."}}} +{"type":"emit_inbound","label":"session.prompt.first.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_subagent_queue_first"}}} +{"type":"emit_inbound","label":"root.input.admitted.first","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_subagent_queued_turn","inputID":"input_opencode2_subagent_queue_first","input":{"id":"input_opencode2_subagent_queue_first","type":"user"}}}}} +{"type":"emit_inbound","label":"root.execution.started.first","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_subagent_queued_turn"}}}} +{"type":"emit_inbound","label":"subagent.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_subagent_queued_turn","assistantMessageID":"message_opencode2_subagent_queue","ordinal":0,"callID":"call_opencode2_subagent_queue","name":"subagent"}}}} +{"type":"emit_inbound","label":"subagent.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_subagent_queued_turn","assistantMessageID":"message_opencode2_subagent_queue","ordinal":0,"callID":"call_opencode2_subagent_queue","input":{"agent":"explore","background":true,"description":"background child fixture","prompt":"Respond exactly CHILD_BACKGROUND_OK"}}}}} +{"type":"emit_inbound","label":"subagent.launch.success","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_subagent_queued_turn","assistantMessageID":"message_opencode2_subagent_queue","ordinal":0,"callID":"call_opencode2_subagent_queue","content":[{"type":"text","text":"Background subagent launched"}],"structured":{"sessionID":"ses_opencode2_child_queue"}}}}} +{"type":"emit_inbound","label":"child.session.created","frame":{"type":"sdk.event","event":{"id":"event_child_created_queue","created":1785391200100,"type":"session.created","data":{"sessionID":"ses_opencode2_child_queue","info":{"id":"ses_opencode2_child_queue","slug":"child-queue","projectID":"global","directory":"/private/tmp/t3-opencode2-subagent-queue","parentID":"ses_opencode2_subagent_queued_turn","title":"background child fixture","agent":"explore","model":{"id":"big-pickle","providerID":"opencode"},"version":"0.0.0-next-16540","time":{"created":1785391200100,"updated":1785391200100}}}}}} +{"type":"emit_inbound","label":"child.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_child_queue","inputID":"input_opencode2_child_queue","input":{"id":"input_opencode2_child_queue","type":"user"}}}}} +{"type":"emit_inbound","label":"child.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_child_queue"}}}} +{"type":"emit_inbound","label":"root.text.started.first","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_subagent_queued_turn","assistantMessageID":"message_opencode2_parent_queue","ordinal":1}}}} +{"type":"emit_inbound","label":"root.text.delta.first","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_subagent_queued_turn","assistantMessageID":"message_opencode2_parent_queue","ordinal":1,"delta":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.text.ended.first","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_subagent_queued_turn","assistantMessageID":"message_opencode2_parent_queue","ordinal":1,"text":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded.first","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_subagent_queued_turn"}}}} +{"type":"emit_inbound","label":"child.permission.asked","frame":{"type":"sdk.event","event":{"type":"permission.asked","data":{"id":"permission_opencode2_child_queue","sessionID":"ses_opencode2_child_queue","permission":"bash","patterns":["pwd"],"metadata":{"command":"pwd"},"always":[],"tool":{"messageID":"message_opencode2_child_queue","callID":"call_opencode2_child_queue"}}}}} +{"type":"expect_outbound","label":"child.session.permission.reply","frame":{"type":"session.permission.reply","input":{"sessionID":"ses_opencode2_child_queue","requestID":"permission_opencode2_child_queue","reply":"once"}}} +{"type":"emit_inbound","label":"child.session.permission.reply.response","frame":{"type":"sdk.response","operation":"session.permission.reply","data":null}} +{"type":"emit_inbound","label":"child.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_child_queue","assistantMessageID":"message_opencode2_child_queue","ordinal":0}}}} +{"type":"emit_inbound","label":"child.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_child_queue","assistantMessageID":"message_opencode2_child_queue","ordinal":0,"delta":"CHILD_BACKGROUND_OK"}}}} +{"type":"emit_inbound","label":"child.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_child_queue","assistantMessageID":"message_opencode2_child_queue","ordinal":0,"text":"CHILD_BACKGROUND_OK"}}}} +{"type":"emit_inbound","label":"child.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_child_queue"}}}} +{"type":"expect_outbound","label":"root.pending.list.first","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_subagent_queued_turn"}}} +{"type":"emit_inbound","label":"root.pending.list.first.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list.first","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-subagent-queue"}}}} +{"type":"emit_inbound","label":"shell.list.first.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"expect_outbound","label":"session.prompt.second","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_subagent_queued_turn","text":"Respond with exactly: second fixture turn complete"}}} +{"type":"emit_inbound","label":"session.prompt.second.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_subagent_queue_second"}}} +{"type":"emit_inbound","label":"wake.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_subagent_queued_turn","inputID":"input_opencode2_subagent_queue_wake","input":{"type":"synthetic","data":{"text":"CHILD_BACKGROUND_OK","description":"background child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"wake.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_subagent_queued_turn"}}}} +{"type":"emit_inbound","label":"wake.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_subagent_queued_turn","assistantMessageID":"message_opencode2_subagent_queue_wake","ordinal":0}}}} +{"type":"emit_inbound","label":"wake.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_subagent_queued_turn","assistantMessageID":"message_opencode2_subagent_queue_wake","ordinal":0,"delta":"CHILD_BACKGROUND_OK"}}}} +{"type":"emit_inbound","label":"wake.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_subagent_queued_turn","assistantMessageID":"message_opencode2_subagent_queue_wake","ordinal":0,"text":"CHILD_BACKGROUND_OK"}}}} +{"type":"emit_inbound","label":"wake.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_subagent_queued_turn"}}}} +{"type":"emit_inbound","label":"root.input.admitted.second","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_subagent_queued_turn","inputID":"input_opencode2_subagent_queue_second","input":{"id":"input_opencode2_subagent_queue_second","type":"user"}}}}} +{"type":"emit_inbound","label":"root.execution.started.second","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_subagent_queued_turn"}}}} +{"type":"emit_inbound","label":"root.text.started.second","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_subagent_queued_turn","assistantMessageID":"message_opencode2_second","ordinal":0}}}} +{"type":"emit_inbound","label":"root.text.delta.second","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_subagent_queued_turn","assistantMessageID":"message_opencode2_second","ordinal":0,"delta":"second fixture turn complete"}}}} +{"type":"emit_inbound","label":"root.text.ended.second","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_subagent_queued_turn","assistantMessageID":"message_opencode2_second","ordinal":0,"text":"second fixture turn complete"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded.second","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_subagent_queued_turn"}}}} +{"type":"expect_outbound","label":"root.pending.list.second","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_subagent_queued_turn"}}} +{"type":"emit_inbound","label":"root.pending.list.second.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list.second","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-subagent-queue"}}}} +{"type":"emit_inbound","label":"shell.list.second.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"expect_outbound","label":"root.pending.list.continuation","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_subagent_queued_turn"}}} +{"type":"emit_inbound","label":"root.pending.list.continuation.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list.continuation","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-subagent-queue"}}}} +{"type":"emit_inbound","label":"shell.list.continuation.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_queued_turn/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_queued_turn/output.ts new file mode 100644 index 00000000000..467afe2e465 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_queued_turn/output.ts @@ -0,0 +1,84 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertRunOrdinals, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + MULTI_TURN_SECOND_PROMPT, + OPENCODE2_SUBAGENT_BACKGROUND_PROMPT, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2SubagentQueuedTurnOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ + result, + transcript, + runCount: 3, + runStatuses: ["completed", "completed", "completed"], + }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertRunOrdinals(projection, [1, 2, 3]); + assertUserMessagesInclude(projection, [ + OPENCODE2_SUBAGENT_BACKGROUND_PROMPT, + MULTI_TURN_SECOND_PROMPT, + ]); + const subagentItem = projection.turnItems.find((candidate) => candidate.type === "subagent"); + assert.strictEqual(subagentItem?.type, "subagent"); + if (subagentItem?.type !== "subagent") { + throw new Error("OpenCode 2 background subagent item is missing"); + } + assert.strictEqual(subagentItem.status, "completed"); + assert.isNotNull(subagentItem.childThreadId); + const child = result.projections.get(subagentItem.childThreadId!); + assert.isDefined(child); + assertAssistantTextIncludes(child!, "CHILD_BACKGROUND_OK"); + assertAssistantTextIncludes(projection, "PARENT_RELEASED"); + assertAssistantTextIncludes(projection, "second fixture turn complete"); + assertAssistantTextIncludes(projection, "CHILD_BACKGROUND_OK"); + + const subagentEvents = result.domainEvents.filter((event) => event.type === "subagent.updated"); + const firstTerminal = subagentEvents.findIndex((event) => + ["completed", "failed", "cancelled", "interrupted"].includes(event.payload.status), + ); + assert.isAtLeast( + firstTerminal, + 1, + "the native launch acknowledgement must precede terminal child status", + ); + assert.isTrue( + subagentEvents + .slice(0, firstTerminal) + .every((event) => event.payload.status === "pending" || event.payload.status === "running"), + "the native launch acknowledgement must not terminalize the linked child", + ); + assert.strictEqual(subagentEvents[firstTerminal]?.payload.status, "completed"); + + const secondRun = projection.runs[1]; + assert.isDefined(secondRun); + const secondRunEvents = result.domainEvents + .filter((event) => event.type === "run.created" || event.type === "run.updated") + .filter((event) => event.runId === secondRun!.id); + assert.equal(secondRunEvents[0]?.type, "run.created"); + assert.equal(secondRunEvents[0]?.payload.status, "queued"); + assert.equal( + secondRunEvents.filter((event) => event.payload.status === "running").length, + 1, + "queued run should promote exactly once after the child settles", + ); + + const continuation = projection.runs[2]; + assert.isDefined(continuation); + const continuationAssistantItems = projection.turnItems.filter( + (item) => item.runId === continuation!.id && item.type === "assistant_message", + ); + assert.lengthOf(continuationAssistantItems, 1); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_rate_limit/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_rate_limit/input.ts new file mode 100644 index 00000000000..0596efd56e3 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_rate_limit/input.ts @@ -0,0 +1,7 @@ +import { OPENCODE2_SUBAGENT_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2SubagentRateLimitInput(): OrchestratorFixtureInput { + return { + steps: [{ type: "message", text: OPENCODE2_SUBAGENT_PROMPT }], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_rate_limit/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_rate_limit/opencode2_transcript.ndjson new file mode 100644 index 00000000000..cd9d8f8392a --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_rate_limit/opencode2_transcript.ndjson @@ -0,0 +1,30 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16540","scenario":"opencode2_subagent_rate_limit","metadata":{"source":"derived-from-live-run","capturedAt":"2026-07-30","nativeSessionId":"ses_opencode2_subagent_rate_limit","model":"opencode/big-pickle","description":"A Full access native child automatically receives a legacy permission reply, exhausts provider-managed HTTP 429 retries, and exposes the terminal rate limit on the child and parent subagent row."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_subagent_rate_limit","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785387600000,"updated":1785387600000},"title":"T3 OpenCode 2 rate-limit subagent replay","location":{"directory":"/private/tmp/t3-opencode2-subagent-rate-limit"}}}} +{"type":"expect_outbound","label":"session.prompt","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_subagent_rate_limit","text":"Use the subagent tool exactly once with description child fixture and prompt Respond exactly CHILD_OK. Then respond exactly PARENT_OK."}}} +{"type":"emit_inbound","label":"session.prompt.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_subagent_rate_limit"}}} +{"type":"emit_inbound","label":"root.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_subagent_rate_limit","inputID":"input_opencode2_subagent_rate_limit","input":{"id":"input_opencode2_subagent_rate_limit","type":"user"}}}}} +{"type":"emit_inbound","label":"root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_subagent_rate_limit"}}}} +{"type":"emit_inbound","label":"subagent.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_subagent_rate_limit","assistantMessageID":"message_opencode2_subagent_rate_limit","ordinal":0,"callID":"call_opencode2_subagent_rate_limit","name":"subagent"}}}} +{"type":"emit_inbound","label":"subagent.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_subagent_rate_limit","assistantMessageID":"message_opencode2_subagent_rate_limit","ordinal":0,"callID":"call_opencode2_subagent_rate_limit","input":{"agent":"explore","description":"child fixture","prompt":"Respond exactly CHILD_OK"}}}}} +{"type":"emit_inbound","label":"child.session.created","frame":{"type":"sdk.event","event":{"id":"event_child_created_rate_limit","created":1785387600100,"type":"session.created","data":{"sessionID":"ses_opencode2_child_rate_limit","info":{"id":"ses_opencode2_child_rate_limit","slug":"child-rate-limit","projectID":"global","directory":"/private/tmp/t3-opencode2-subagent-rate-limit","parentID":"ses_opencode2_subagent_rate_limit","title":"child fixture","agent":"explore","model":{"id":"big-pickle","providerID":"opencode"},"version":"0.0.0-next-16540","time":{"created":1785387600100,"updated":1785387600100}}}}}} +{"type":"emit_inbound","label":"child.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_child_rate_limit","inputID":"input_opencode2_child_rate_limit","input":{"id":"input_opencode2_child_rate_limit","type":"user"}}}}} +{"type":"emit_inbound","label":"child.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_child_rate_limit"}}}} +{"type":"emit_inbound","label":"child.permission.asked","frame":{"type":"sdk.event","event":{"id":"event_child_permission_rate_limit","created":1785387600200,"type":"permission.asked","data":{"id":"permission_opencode2_child_rate_limit","sessionID":"ses_opencode2_child_rate_limit","permission":"read","patterns":["package.json"],"metadata":{},"always":["*"]}}}} +{"type":"expect_outbound","label":"child.session.permission.reply","frame":{"type":"session.permission.reply","input":{"sessionID":"ses_opencode2_child_rate_limit","requestID":"permission_opencode2_child_rate_limit","reply":"once"}}} +{"type":"emit_inbound","label":"child.session.permission.reply.response","frame":{"type":"sdk.response","operation":"session.permission.reply","data":null}} +{"type":"emit_inbound","label":"child.retry.scheduled","frame":{"type":"sdk.event","event":{"id":"event_child_retry_rate_limit","created":1785387600300,"type":"session.retry.scheduled","data":{"sessionID":"ses_opencode2_child_rate_limit","assistantMessageID":"message_opencode2_child_rate_limit","attempt":5,"at":1785387616300,"error":{"type":"provider.rate-limit","message":"HTTP 429: Rate limit exceeded"}}}}} +{"type":"emit_inbound","label":"child.execution.failed","frame":{"type":"sdk.event","event":{"id":"event_child_failed_rate_limit","created":1785387616300,"type":"session.execution.failed","data":{"sessionID":"ses_opencode2_child_rate_limit","error":{"type":"provider.rate-limit","message":"HTTP 429: Rate limit exceeded"}}}}} +{"type":"emit_inbound","label":"subagent.failed","frame":{"type":"sdk.event","event":{"type":"session.tool.failed","data":{"sessionID":"ses_opencode2_subagent_rate_limit","assistantMessageID":"message_opencode2_subagent_rate_limit","callID":"call_opencode2_subagent_rate_limit","error":{"type":"provider.rate-limit","message":"HTTP 429: Rate limit exceeded"},"executed":true}}}} +{"type":"emit_inbound","label":"root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_subagent_rate_limit","assistantMessageID":"message_opencode2_parent_rate_limit","ordinal":1}}}} +{"type":"emit_inbound","label":"root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_subagent_rate_limit","assistantMessageID":"message_opencode2_parent_rate_limit","ordinal":1,"delta":"PARENT_AFTER_429"}}}} +{"type":"emit_inbound","label":"root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_subagent_rate_limit","assistantMessageID":"message_opencode2_parent_rate_limit","ordinal":1,"text":"PARENT_AFTER_429"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_subagent_rate_limit"}}}} +{"type":"expect_outbound","label":"root.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_subagent_rate_limit"}}} +{"type":"emit_inbound","label":"root.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-subagent-rate-limit"}}}} +{"type":"emit_inbound","label":"root.shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_rate_limit/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_rate_limit/output.ts new file mode 100644 index 00000000000..3e987e8d39e --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_rate_limit/output.ts @@ -0,0 +1,37 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2SubagentRateLimitOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + const item = projection.turnItems.find((candidate) => candidate.type === "subagent"); + assert.strictEqual(item?.type, "subagent"); + if (item?.type !== "subagent") throw new Error("OpenCode 2 subagent item is missing"); + assert.strictEqual(item.status, "failed"); + assert.match(item.result ?? "", /HTTP 429/); + assert.isNotNull(item.childThreadId); + const child = result.projections.get(item.childThreadId!); + assert.isDefined(child); + const failure = child!.turnItems.find((candidate) => candidate.type === "error"); + assert.strictEqual(failure?.type, "error"); + if (failure?.type !== "error") throw new Error("OpenCode 2 child failure item is missing"); + assert.strictEqual(failure.status, "failed"); + assert.strictEqual(failure.failure.code, "provider.rate-limit"); + assert.strictEqual(failure.failure.retryable, true); + assert.strictEqual(failure.retry?.attempt, 5); + assertAssistantTextIncludes(projection, "PARENT_AFTER_429"); + assert.strictEqual(projection.subagents[0]?.status, "failed"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_supervised/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_supervised/input.ts new file mode 100644 index 00000000000..4c3006a2913 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_supervised/input.ts @@ -0,0 +1,10 @@ +import { OPENCODE2_SUBAGENT_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2SubagentSupervisedInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_SUBAGENT_PROMPT }, + { type: "approve_next_runtime_request", decision: "accept" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_supervised/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_supervised/opencode2_transcript.ndjson new file mode 100644 index 00000000000..21ab161ba83 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_supervised/opencode2_transcript.ndjson @@ -0,0 +1,33 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16540","scenario":"opencode2_subagent_supervised","metadata":{"source":"derived-from-live-run","capturedAt":"2026-07-30","nativeSessionId":"ses_opencode2_subagent_supervised","model":"opencode/big-pickle","description":"A native child session is linked through parentID, emits a legacy permission request under Supervised mode, resumes after Allow once, and completes with durable lineage."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_subagent_supervised","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785384000000,"updated":1785384000000},"title":"T3 OpenCode 2 supervised subagent replay","location":{"directory":"/private/tmp/t3-opencode2-subagent-supervised"}}}} +{"type":"expect_outbound","label":"session.prompt","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_subagent_supervised","text":"Use the subagent tool exactly once with description child fixture and prompt Respond exactly CHILD_OK. Then respond exactly PARENT_OK."}}} +{"type":"emit_inbound","label":"session.prompt.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_subagent_supervised"}}} +{"type":"emit_inbound","label":"root.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_subagent_supervised","inputID":"input_opencode2_subagent_supervised","input":{"id":"input_opencode2_subagent_supervised","type":"user"}}}}} +{"type":"emit_inbound","label":"root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_subagent_supervised"}}}} +{"type":"emit_inbound","label":"subagent.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_subagent_supervised","assistantMessageID":"message_opencode2_subagent_supervised","ordinal":0,"callID":"call_opencode2_subagent_supervised","name":"subagent"}}}} +{"type":"emit_inbound","label":"subagent.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_subagent_supervised","assistantMessageID":"message_opencode2_subagent_supervised","ordinal":0,"callID":"call_opencode2_subagent_supervised","input":{"agent":"explore","description":"child fixture","prompt":"Respond exactly CHILD_OK"}}}}} +{"type":"emit_inbound","label":"child.session.created","frame":{"type":"sdk.event","event":{"id":"event_child_created_supervised","created":1785384000100,"type":"session.created","data":{"sessionID":"ses_opencode2_child_supervised","info":{"id":"ses_opencode2_child_supervised","slug":"child-supervised","projectID":"global","directory":"/private/tmp/t3-opencode2-subagent-supervised","parentID":"ses_opencode2_subagent_supervised","title":"child fixture","agent":"explore","model":{"id":"big-pickle","providerID":"opencode"},"version":"0.0.0-next-16540","time":{"created":1785384000100,"updated":1785384000100}}}}}} +{"type":"emit_inbound","label":"child.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_child_supervised","inputID":"input_opencode2_child_supervised","input":{"id":"input_opencode2_child_supervised","type":"user"}}}}} +{"type":"emit_inbound","label":"child.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_child_supervised"}}}} +{"type":"emit_inbound","label":"child.permission.asked","frame":{"type":"sdk.event","event":{"id":"event_child_permission_supervised","created":1785384000200,"type":"permission.asked","data":{"id":"permission_opencode2_child_supervised","sessionID":"ses_opencode2_child_supervised","permission":"bash","patterns":["pwd"],"metadata":{},"always":["*"]}}}} +{"type":"expect_outbound","label":"child.session.permission.reply","frame":{"type":"session.permission.reply","input":{"sessionID":"ses_opencode2_child_supervised","requestID":"permission_opencode2_child_supervised","reply":"once"}}} +{"type":"emit_inbound","label":"child.session.permission.reply.response","frame":{"type":"sdk.response","operation":"session.permission.reply","data":null}} +{"type":"emit_inbound","label":"child.permission.replied","frame":{"type":"sdk.event","event":{"id":"event_child_permission_replied_supervised","created":1785384000300,"type":"permission.replied","data":{"sessionID":"ses_opencode2_child_supervised","requestID":"permission_opencode2_child_supervised","reply":"once"}}}} +{"type":"emit_inbound","label":"child.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_child_supervised","assistantMessageID":"message_opencode2_child_supervised","ordinal":0}}}} +{"type":"emit_inbound","label":"child.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_child_supervised","assistantMessageID":"message_opencode2_child_supervised","ordinal":0,"delta":"CHILD_OK"}}}} +{"type":"emit_inbound","label":"child.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_child_supervised","assistantMessageID":"message_opencode2_child_supervised","ordinal":0,"text":"CHILD_OK"}}}} +{"type":"emit_inbound","label":"child.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_child_supervised"}}}} +{"type":"emit_inbound","label":"subagent.success","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_subagent_supervised","assistantMessageID":"message_opencode2_subagent_supervised","ordinal":0,"callID":"call_opencode2_subagent_supervised","content":[{"type":"text","text":"CHILD_OK"}],"structured":{"sessionID":"ses_opencode2_child_supervised"}}}}} +{"type":"emit_inbound","label":"root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_subagent_supervised","assistantMessageID":"message_opencode2_parent_supervised","ordinal":1}}}} +{"type":"emit_inbound","label":"root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_subagent_supervised","assistantMessageID":"message_opencode2_parent_supervised","ordinal":1,"delta":"PARENT_OK"}}}} +{"type":"emit_inbound","label":"root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_subagent_supervised","assistantMessageID":"message_opencode2_parent_supervised","ordinal":1,"text":"PARENT_OK"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_subagent_supervised"}}}} +{"type":"expect_outbound","label":"root.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_subagent_supervised"}}} +{"type":"emit_inbound","label":"root.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-subagent-supervised"}}}} +{"type":"emit_inbound","label":"root.shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_supervised/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_supervised/output.ts new file mode 100644 index 00000000000..b73a527d632 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_subagent_supervised/output.ts @@ -0,0 +1,37 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAllRuntimeRequestsResolved, + assertAssistantTextIncludes, + assertBaseProjection, + assertRuntimeRequestCounts, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2SubagentSupervisedOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + const item = projection.turnItems.find((candidate) => candidate.type === "subagent"); + assert.strictEqual(item?.type, "subagent"); + if (item?.type !== "subagent") throw new Error("OpenCode 2 subagent item is missing"); + assert.strictEqual(item.status, "completed"); + assert.isNotNull(item.childThreadId); + const child = result.projections.get(item.childThreadId!); + assert.isDefined(child); + assert.strictEqual(child!.thread.lineage.parentThreadId, projection.thread.id); + assert.strictEqual(child!.thread.lineage.relationshipToParent, "subagent"); + assertRuntimeRequestCounts(projection, { total: 1, resolved: 1 }); + assertAllRuntimeRequestsResolved(projection); + assertRuntimeRequestCounts(child!, { total: 0 }); + assertAssistantTextIncludes(child!, "CHILD_OK"); + assertAssistantTextIncludes(projection, "PARENT_OK"); + assert.strictEqual(projection.subagents[0]?.status, "completed"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_thread_delete/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_thread_delete/input.ts new file mode 100644 index 00000000000..a6cf8c739fa --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_thread_delete/input.ts @@ -0,0 +1,11 @@ +import { OPENCODE2_THREAD_DELETE_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +export function openCode2ThreadDeleteInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_THREAD_DELETE_PROMPT }, + { type: "advance_clock", duration: "31 minutes" }, + { type: "delete" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_thread_delete/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_thread_delete/opencode2_transcript.ndjson new file mode 100644 index 00000000000..472aa228fdf --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_thread_delete/opencode2_transcript.ndjson @@ -0,0 +1,24 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16383","scenario":"opencode2_thread_delete","metadata":{"source":"sdk-contract-replay","capturedAt":"2026-07-29","nativeSessionId":"ses_opencode2_thread_delete","model":"opencode/big-pickle","description":"Deleting the application thread removes its active provider-native OpenCode 2 session before the exclusive runtime detaches."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_thread_delete","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785297600000,"updated":1785297600000},"title":"T3 OpenCode 2 delete replay","location":{"directory":"/private/tmp/t3-opencode2-delete-replay"}}}} +{"type":"expect_outbound","label":"session.prompt","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_thread_delete","text":"Respond exactly: native deletion fixture complete"}}} +{"type":"emit_inbound","label":"session.prompt.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_thread_delete"}}} +{"type":"emit_inbound","label":"session.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_thread_delete","inputID":"input_opencode2_thread_delete","input":{"id":"input_opencode2_thread_delete","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_thread_delete"}}}} +{"type":"emit_inbound","label":"session.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_thread_delete","assistantMessageID":"message_opencode2_thread_delete","ordinal":0}}}} +{"type":"emit_inbound","label":"session.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_thread_delete","assistantMessageID":"message_opencode2_thread_delete","ordinal":0,"text":"native deletion fixture complete"}}}} +{"type":"emit_inbound","label":"session.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_thread_delete"}}}} +{"type":"expect_outbound","label":"session.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_thread_delete"}}} +{"type":"emit_inbound","label":"session.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-delete-replay"}}}} +{"type":"emit_inbound","label":"shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"expect_outbound","label":"session.pending.list.idle-release","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_thread_delete"}}} +{"type":"emit_inbound","label":"session.pending.list.idle-release.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list.idle-release","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-delete-replay"}}}} +{"type":"emit_inbound","label":"shell.list.idle-release.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"expect_outbound","label":"session.remove","frame":{"type":"session.remove","input":{"sessionID":"ses_opencode2_thread_delete"}}} +{"type":"emit_inbound","label":"session.remove.response","frame":{"type":"sdk.response","operation":"session.remove","data":true}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_thread_delete/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_thread_delete/output.ts new file mode 100644 index 00000000000..52509f8e0b7 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_thread_delete/output.ts @@ -0,0 +1,48 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + OPENCODE2_THREAD_DELETE_PROMPT, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2ThreadDeleteOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertUserMessagesInclude(projection, [OPENCODE2_THREAD_DELETE_PROMPT]); + assertAssistantTextIncludes(projection, "native deletion fixture complete"); + assert.isNotNull(projection.thread.deletedAt); + const stoppedIndex = result.domainEvents.findIndex( + (event) => event.type === "provider-session.updated" && event.payload.status === "stopped", + ); + const detachedIndex = result.domainEvents.findIndex( + (event) => + event.type === "provider-session.detached" && event.threadId === projection.thread.id, + ); + assert.isAtLeast( + stoppedIndex, + 0, + "the fixture's idle release must stop the managed runtime before application deletion", + ); + assert.isAbove( + detachedIndex, + stoppedIndex, + "the fixture must detach the provider session after its idle release stops the runtime", + ); + assert.isFalse(result.shellSnapshot.threads.some((thread) => thread.id === projection.thread.id)); + + const removeIndex = transcript.entries.findIndex( + (entry) => entry.type === "expect_outbound" && entry.label === "session.remove", + ); + assert.isAtLeast(removeIndex, 0, "application deletion must remove the native session"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_replay/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_replay/input.ts new file mode 100644 index 00000000000..f2bcc47918e --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_replay/input.ts @@ -0,0 +1,14 @@ +import { + OPENCODE2_TWO_COMPLETED_SUBAGENT_PROMPT, + type OrchestratorFixtureInput, +} from "../shared.ts"; + +export function openCode2TwoBackgroundChildReplayInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_TWO_COMPLETED_SUBAGENT_PROMPT }, + { type: "provider_continuation" }, + { type: "provider_continuation" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_replay/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_replay/opencode2_transcript.ndjson new file mode 100644 index 00000000000..ee4e524786b --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_replay/opencode2_transcript.ndjson @@ -0,0 +1,64 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16540","scenario":"opencode2_two_background_child_replay","metadata":{"source":"focused-provider-native-stop","description":"Two completed OpenCode 2 background children are promoted into distinct native executions. Wake A settles, wake B starts, and the first continuation replays before B completes; the second continuation then consumes B's retained events."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_two_background_child_replay","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785394800000,"updated":1785394800000},"title":"T3 OpenCode 2 two-child background replay","location":{"directory":"/private/tmp/t3-opencode2-two-background-child-replay"}}}} +{"type":"expect_outbound","label":"session.prompt.root","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_two_background_child_replay","text":"Start two background subagents: one with description alpha completed child fixture and prompt Respond exactly ALPHA_COMPLETED_OK, and one with description bravo completed child fixture and prompt Respond exactly BRAVO_COMPLETED_OK. Then respond exactly PARENT_RELEASED without waiting for either child."}}} +{"type":"emit_inbound","label":"session.prompt.root.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_two_background_child_replay_root"}}} +{"type":"emit_inbound","label":"root.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_two_background_child_replay","inputID":"input_opencode2_two_background_child_replay_root","input":{"type":"user","data":{"text":"Start two background subagents"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"root.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_two_background_child_replay","inputID":"input_opencode2_two_background_child_replay_root"}}}} +{"type":"emit_inbound","label":"root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_two_background_child_replay"}}}} +{"type":"emit_inbound","label":"alpha.tool.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_root","ordinal":0,"callID":"call_opencode2_two_background_child_replay_alpha","name":"subagent"}}}} +{"type":"emit_inbound","label":"alpha.tool.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_root","ordinal":0,"callID":"call_opencode2_two_background_child_replay_alpha","input":{"agent":"explore","background":true,"description":"alpha completed child fixture","prompt":"Respond exactly ALPHA_COMPLETED_OK"}}}}} +{"type":"emit_inbound","label":"alpha.tool.success","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_root","ordinal":0,"callID":"call_opencode2_two_background_child_replay_alpha","content":[{"type":"text","text":"Background subagent launched"}],"structured":{"sessionID":"ses_opencode2_two_background_child_replay_alpha"}}}}} +{"type":"emit_inbound","label":"alpha.session.created","frame":{"type":"sdk.event","event":{"id":"event_opencode2_two_background_child_replay_alpha","created":1785394800100,"type":"session.created","data":{"sessionID":"ses_opencode2_two_background_child_replay_alpha","info":{"id":"ses_opencode2_two_background_child_replay_alpha","slug":"alpha-completed-child","projectID":"global","directory":"/private/tmp/t3-opencode2-two-background-child-replay","parentID":"ses_opencode2_two_background_child_replay","title":"alpha completed child fixture","agent":"explore","model":{"id":"big-pickle","providerID":"opencode"},"version":"0.0.0-next-16540","time":{"created":1785394800100,"updated":1785394800100}}}}}} +{"type":"emit_inbound","label":"alpha.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_two_background_child_replay_alpha","inputID":"input_opencode2_two_background_child_replay_alpha","input":{"type":"user","data":{"text":"Respond exactly ALPHA_COMPLETED_OK"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"alpha.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_two_background_child_replay_alpha","inputID":"input_opencode2_two_background_child_replay_alpha"}}}} +{"type":"emit_inbound","label":"alpha.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_two_background_child_replay_alpha"}}}} +{"type":"emit_inbound","label":"alpha.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_two_background_child_replay_alpha","assistantMessageID":"message_opencode2_two_background_child_replay_alpha","ordinal":0}}}} +{"type":"emit_inbound","label":"alpha.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_two_background_child_replay_alpha","assistantMessageID":"message_opencode2_two_background_child_replay_alpha","ordinal":0,"delta":"ALPHA_COMPLETED_OK"}}}} +{"type":"emit_inbound","label":"alpha.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_two_background_child_replay_alpha","assistantMessageID":"message_opencode2_two_background_child_replay_alpha","ordinal":0,"text":"ALPHA_COMPLETED_OK"}}}} +{"type":"emit_inbound","label":"alpha.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_two_background_child_replay_alpha"}}}} +{"type":"emit_inbound","label":"bravo.tool.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_root","ordinal":1,"callID":"call_opencode2_two_background_child_replay_bravo","name":"subagent"}}}} +{"type":"emit_inbound","label":"bravo.tool.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_root","ordinal":1,"callID":"call_opencode2_two_background_child_replay_bravo","input":{"agent":"explore","background":true,"description":"bravo completed child fixture","prompt":"Respond exactly BRAVO_COMPLETED_OK"}}}}} +{"type":"emit_inbound","label":"bravo.tool.success","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_root","ordinal":1,"callID":"call_opencode2_two_background_child_replay_bravo","content":[{"type":"text","text":"Background subagent launched"}],"structured":{"sessionID":"ses_opencode2_two_background_child_replay_bravo"}}}}} +{"type":"emit_inbound","label":"bravo.session.created","frame":{"type":"sdk.event","event":{"id":"event_opencode2_two_background_child_replay_bravo","created":1785394800200,"type":"session.created","data":{"sessionID":"ses_opencode2_two_background_child_replay_bravo","info":{"id":"ses_opencode2_two_background_child_replay_bravo","slug":"bravo-completed-child","projectID":"global","directory":"/private/tmp/t3-opencode2-two-background-child-replay","parentID":"ses_opencode2_two_background_child_replay","title":"bravo completed child fixture","agent":"explore","model":{"id":"big-pickle","providerID":"opencode"},"version":"0.0.0-next-16540","time":{"created":1785394800200,"updated":1785394800200}}}}}} +{"type":"emit_inbound","label":"bravo.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_two_background_child_replay_bravo","inputID":"input_opencode2_two_background_child_replay_bravo","input":{"type":"user","data":{"text":"Respond exactly BRAVO_COMPLETED_OK"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"bravo.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_two_background_child_replay_bravo","inputID":"input_opencode2_two_background_child_replay_bravo"}}}} +{"type":"emit_inbound","label":"bravo.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_two_background_child_replay_bravo"}}}} +{"type":"emit_inbound","label":"bravo.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_two_background_child_replay_bravo","assistantMessageID":"message_opencode2_two_background_child_replay_bravo","ordinal":0}}}} +{"type":"emit_inbound","label":"bravo.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_two_background_child_replay_bravo","assistantMessageID":"message_opencode2_two_background_child_replay_bravo","ordinal":0,"delta":"BRAVO_COMPLETED_OK"}}}} +{"type":"emit_inbound","label":"bravo.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_two_background_child_replay_bravo","assistantMessageID":"message_opencode2_two_background_child_replay_bravo","ordinal":0,"text":"BRAVO_COMPLETED_OK"}}}} +{"type":"emit_inbound","label":"bravo.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_two_background_child_replay_bravo"}}}} +{"type":"emit_inbound","label":"root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_root","ordinal":2}}}} +{"type":"emit_inbound","label":"root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_root","ordinal":2,"delta":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_root","ordinal":2,"text":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_two_background_child_replay"}}}} +{"type":"expect_outbound","label":"root.pending.list.after.root","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_two_background_child_replay"}}} +{"type":"emit_inbound","label":"root.pending.list.after.root.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list.after.root","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-two-background-child-replay"}}}} +{"type":"emit_inbound","label":"root.shell.list.after.root.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"emit_inbound","label":"wake.alpha.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_two_background_child_replay","inputID":"input_opencode2_two_background_child_replay_wake_alpha","input":{"type":"synthetic","data":{"text":"ALPHA_COMPLETED_OK","description":"alpha completed child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"wake.bravo.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_two_background_child_replay","inputID":"input_opencode2_two_background_child_replay_wake_bravo","input":{"type":"synthetic","data":{"text":"BRAVO_COMPLETED_OK","description":"bravo completed child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"wake.alpha.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_two_background_child_replay","inputID":"input_opencode2_two_background_child_replay_wake_alpha"}}}} +{"type":"emit_inbound","label":"wake.alpha.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_two_background_child_replay"}}}} +{"type":"emit_inbound","label":"wake.alpha.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_wake_alpha","ordinal":0}}}} +{"type":"emit_inbound","label":"wake.alpha.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_wake_alpha","ordinal":0,"delta":"ALPHA_COMPLETED_OK"}}}} +{"type":"emit_inbound","label":"wake.alpha.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_wake_alpha","ordinal":0,"text":"ALPHA_COMPLETED_OK"}}}} +{"type":"emit_inbound","label":"wake.alpha.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_two_background_child_replay"}}}} +{"type":"emit_inbound","label":"wake.bravo.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_two_background_child_replay","inputID":"input_opencode2_two_background_child_replay_wake_bravo"}}}} +{"type":"emit_inbound","label":"wake.bravo.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_two_background_child_replay"}}}} +{"type":"expect_outbound","label":"root.pending.list.first.continuation","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_two_background_child_replay"}}} +{"type":"emit_inbound","label":"root.pending.list.first.continuation.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list.first.continuation","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-two-background-child-replay"}}}} +{"type":"emit_inbound","label":"root.shell.list.first.continuation.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"emit_inbound","label":"wake.bravo.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_wake_bravo","ordinal":0}}}} +{"type":"emit_inbound","label":"wake.bravo.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_wake_bravo","ordinal":0,"delta":"BRAVO_COMPLETED_OK"}}}} +{"type":"emit_inbound","label":"wake.bravo.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_two_background_child_replay","assistantMessageID":"message_opencode2_two_background_child_replay_wake_bravo","ordinal":0,"text":"BRAVO_COMPLETED_OK"}}}} +{"type":"emit_inbound","label":"wake.bravo.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_two_background_child_replay"}}}} +{"type":"expect_outbound","label":"root.pending.list.second.continuation","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_two_background_child_replay"}}} +{"type":"emit_inbound","label":"root.pending.list.second.continuation.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list.second.continuation","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-two-background-child-replay"}}}} +{"type":"emit_inbound","label":"root.shell.list.second.continuation.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_replay/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_replay/output.ts new file mode 100644 index 00000000000..505879eca17 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_replay/output.ts @@ -0,0 +1,52 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2TwoBackgroundChildReplayOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + const alphaSettled = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "wake.alpha.execution.succeeded", + ); + const bravoStarted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "wake.bravo.execution.started", + ); + const firstContinuation = transcript.entries.findIndex( + (entry) => + entry.type === "expect_outbound" && entry.label === "root.pending.list.first.continuation", + ); + assert.isAtLeast(alphaSettled, 0); + assert.isAtLeast(bravoStarted, 0); + assert.isAtLeast(firstContinuation, 0); + assert.isAbove(bravoStarted, alphaSettled); + assert.isAbove(firstContinuation, bravoStarted); + + const projection = projectionFor(result, transcript.scenario); + assertBaseProjection({ + result, + transcript, + runCount: 3, + runStatuses: ["completed", "completed", "completed"], + }); + assertSemanticProjectionIntegrity(projection); + assertAssistantTextIncludes(projection, "PARENT_RELEASED"); + assertAssistantTextIncludes(projection, "ALPHA_COMPLETED_OK"); + assertAssistantTextIncludes(projection, "BRAVO_COMPLETED_OK"); + assert.notInclude(JSON.stringify(projection), "CANCELLED_OUTPUT_MUST_NOT_APPEAR"); + + const subagentItems = projection.turnItems.filter((item) => item.type === "subagent"); + assert.lengthOf(subagentItems, 2); + for (const item of subagentItems) { + assert.equal(item.status, "completed"); + const child = result.projections.get(item.childThreadId!); + assert.isDefined(child); + } +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_stop/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_stop/input.ts new file mode 100644 index 00000000000..e3740bfde6c --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_stop/input.ts @@ -0,0 +1,17 @@ +import { + OPENCODE2_TWO_SUBAGENT_BACKGROUND_PROMPT, + type OrchestratorFixtureInput, +} from "../shared.ts"; + +export function openCode2TwoBackgroundChildStopInput(): OrchestratorFixtureInput { + return { + steps: [ + { type: "message", text: OPENCODE2_TWO_SUBAGENT_BACKGROUND_PROMPT }, + { + type: "interrupt_provider_native", + subagentNativeItemId: "tool:call_opencode2_two_background_child_stop_cancel", + }, + { type: "provider_continuation" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_stop/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_stop/opencode2_transcript.ndjson new file mode 100644 index 00000000000..426c56d5fa7 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_stop/opencode2_transcript.ndjson @@ -0,0 +1,69 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16540","scenario":"opencode2_two_background_child_stop","metadata":{"source":"focused-provider-native-stop","description":"Two directly-owned OpenCode 2 background children use separate native execution boundaries. The completed child wake is replayed after the cancelled child starts its live suppressed execution, and the cancelled wake must retain that execution owner until it drains."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_two_background_child_stop","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785394800000,"updated":1785394800000},"title":"T3 OpenCode 2 two-child background Stop replay","location":{"directory":"/private/tmp/t3-opencode2-two-background-child-stop"}}}} +{"type":"expect_outbound","label":"session.prompt.root","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_two_background_child_stop","text":"Start two background subagents: one with description alpha background child fixture and prompt Respond exactly ALPHA_BACKGROUND_OK, and one with description cancel background child fixture and prompt Respond exactly CANCEL_BACKGROUND_PARTIAL. Then respond exactly PARENT_RELEASED without waiting for either child."}}} +{"type":"emit_inbound","label":"session.prompt.root.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_two_background_child_stop_root"}}} +{"type":"emit_inbound","label":"root.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_two_background_child_stop","inputID":"input_opencode2_two_background_child_stop_root","input":{"type":"user","data":{"text":"Start two background subagents"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"root.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_two_background_child_stop","inputID":"input_opencode2_two_background_child_stop_root"}}}} +{"type":"emit_inbound","label":"root.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_two_background_child_stop"}}}} +{"type":"emit_inbound","label":"alpha.tool.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_root","ordinal":0,"callID":"call_opencode2_two_background_child_stop_alpha","name":"subagent"}}}} +{"type":"emit_inbound","label":"alpha.tool.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_root","ordinal":0,"callID":"call_opencode2_two_background_child_stop_alpha","input":{"agent":"explore","background":true,"description":"alpha background child fixture","prompt":"Respond exactly ALPHA_BACKGROUND_OK"}}}}} +{"type":"emit_inbound","label":"alpha.tool.success","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_root","ordinal":0,"callID":"call_opencode2_two_background_child_stop_alpha","content":[{"type":"text","text":"Background subagent launched"}],"structured":{"sessionID":"ses_opencode2_two_background_child_stop_alpha"}}}}} +{"type":"emit_inbound","label":"alpha.session.created","frame":{"type":"sdk.event","event":{"id":"event_opencode2_two_background_child_stop_alpha","created":1785394800100,"type":"session.created","data":{"sessionID":"ses_opencode2_two_background_child_stop_alpha","info":{"id":"ses_opencode2_two_background_child_stop_alpha","slug":"alpha-background-child","projectID":"global","directory":"/private/tmp/t3-opencode2-two-background-child-stop","parentID":"ses_opencode2_two_background_child_stop","title":"alpha background child fixture","agent":"explore","model":{"id":"big-pickle","providerID":"opencode"},"version":"0.0.0-next-16540","time":{"created":1785394800100,"updated":1785394800100}}}}}} +{"type":"emit_inbound","label":"alpha.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_two_background_child_stop_alpha","inputID":"input_opencode2_two_background_child_stop_alpha","input":{"type":"user","data":{"text":"Respond exactly ALPHA_BACKGROUND_OK"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"alpha.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_two_background_child_stop_alpha","inputID":"input_opencode2_two_background_child_stop_alpha"}}}} +{"type":"emit_inbound","label":"alpha.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_two_background_child_stop_alpha"}}}} +{"type":"emit_inbound","label":"alpha.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_two_background_child_stop_alpha","assistantMessageID":"message_opencode2_two_background_child_stop_alpha","ordinal":0}}}} +{"type":"emit_inbound","label":"alpha.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_two_background_child_stop_alpha","assistantMessageID":"message_opencode2_two_background_child_stop_alpha","ordinal":0,"delta":"ALPHA_BACKGROUND_OK"}}}} +{"type":"emit_inbound","label":"alpha.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_two_background_child_stop_alpha","assistantMessageID":"message_opencode2_two_background_child_stop_alpha","ordinal":0,"text":"ALPHA_BACKGROUND_OK"}}}} +{"type":"emit_inbound","label":"alpha.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_two_background_child_stop_alpha"}}}} +{"type":"emit_inbound","label":"cancel.tool.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_root","ordinal":1,"callID":"call_opencode2_two_background_child_stop_cancel","name":"subagent"}}}} +{"type":"emit_inbound","label":"cancel.tool.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_root","ordinal":1,"callID":"call_opencode2_two_background_child_stop_cancel","input":{"agent":"explore","background":true,"description":"cancel background child fixture","prompt":"Respond exactly CANCEL_BACKGROUND_PARTIAL"}}}}} +{"type":"emit_inbound","label":"cancel.tool.success","frame":{"type":"sdk.event","event":{"type":"session.tool.success","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_root","ordinal":1,"callID":"call_opencode2_two_background_child_stop_cancel","content":[{"type":"text","text":"Background subagent launched"}],"structured":{"sessionID":"ses_opencode2_two_background_child_stop_cancel"}}}}} +{"type":"emit_inbound","label":"cancel.session.created","frame":{"type":"sdk.event","event":{"id":"event_opencode2_two_background_child_stop_cancel","created":1785394800200,"type":"session.created","data":{"sessionID":"ses_opencode2_two_background_child_stop_cancel","info":{"id":"ses_opencode2_two_background_child_stop_cancel","slug":"cancel-background-child","projectID":"global","directory":"/private/tmp/t3-opencode2-two-background-child-stop","parentID":"ses_opencode2_two_background_child_stop","title":"cancel background child fixture","agent":"explore","model":{"id":"big-pickle","providerID":"opencode"},"version":"0.0.0-next-16540","time":{"created":1785394800200,"updated":1785394800200}}}}}} +{"type":"emit_inbound","label":"cancel.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_two_background_child_stop_cancel","inputID":"input_opencode2_two_background_child_stop_cancel","input":{"type":"user","data":{"text":"Respond exactly CANCEL_BACKGROUND_PARTIAL"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"cancel.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_two_background_child_stop_cancel","inputID":"input_opencode2_two_background_child_stop_cancel"}}}} +{"type":"emit_inbound","label":"cancel.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_two_background_child_stop_cancel"}}}} +{"type":"emit_inbound","label":"cancel.tool.input.started","frame":{"type":"sdk.event","event":{"type":"session.tool.input.started","data":{"sessionID":"ses_opencode2_two_background_child_stop_cancel","assistantMessageID":"message_opencode2_two_background_child_stop_cancel","ordinal":0,"callID":"call_opencode2_two_background_child_stop_shell","name":"bash"}}}} +{"type":"emit_inbound","label":"cancel.tool.called","frame":{"type":"sdk.event","event":{"type":"session.tool.called","data":{"sessionID":"ses_opencode2_two_background_child_stop_cancel","assistantMessageID":"message_opencode2_two_background_child_stop_cancel","ordinal":0,"callID":"call_opencode2_two_background_child_stop_shell","input":{"command":"sleep 30 && echo cancel partial"}}}}} +{"type":"emit_inbound","label":"cancel.shell.created","frame":{"type":"sdk.event","event":{"type":"shell.created","data":{"info":{"id":"shell_opencode2_two_background_child_stop_cancel","status":"running","command":"sleep 30 && echo cancel partial","cwd":"/private/tmp/t3-opencode2-two-background-child-stop","shell":"/bin/bash","file":"/private/tmp/t3-opencode2-two-background-child-stop/shell.log","pid":4247,"metadata":{"sessionID":"ses_opencode2_two_background_child_stop_cancel"},"time":{"started":1785394800300}}}}}} +{"type":"emit_inbound","label":"cancel.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_two_background_child_stop_cancel","assistantMessageID":"message_opencode2_two_background_child_stop_cancel_partial","ordinal":1}}}} +{"type":"emit_inbound","label":"cancel.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_two_background_child_stop_cancel","assistantMessageID":"message_opencode2_two_background_child_stop_cancel_partial","ordinal":1,"delta":"CANCEL_PARTIAL"}}}} +{"type":"emit_inbound","label":"cancel.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_two_background_child_stop_cancel","assistantMessageID":"message_opencode2_two_background_child_stop_cancel_partial","ordinal":1,"text":"CANCEL_PARTIAL"}}}} +{"type":"emit_inbound","label":"root.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_root","ordinal":2}}}} +{"type":"emit_inbound","label":"root.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_root","ordinal":2,"delta":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_root","ordinal":2,"text":"PARENT_RELEASED"}}}} +{"type":"emit_inbound","label":"root.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_two_background_child_stop"}}}} +{"type":"expect_outbound","label":"cancel.session.interrupt","frame":{"type":"session.interrupt","input":{"sessionID":"ses_opencode2_two_background_child_stop_cancel"}}} +{"type":"emit_inbound","label":"cancel.session.interrupt.response","frame":{"type":"sdk.response","operation":"session.interrupt","data":true}} +{"type":"expect_outbound","label":"cancel.shell.remove","frame":{"type":"shell.remove","input":{"id":"shell_opencode2_two_background_child_stop_cancel","location":{"directory":"/private/tmp/t3-opencode2-two-background-child-stop"}}}} +{"type":"emit_inbound","label":"cancel.shell.remove.response","frame":{"type":"sdk.response","operation":"shell.remove","data":true}} +{"type":"emit_inbound","label":"cancel.shell.deleted","frame":{"type":"sdk.event","event":{"type":"shell.deleted","data":{"id":"shell_opencode2_two_background_child_stop_cancel"}}}} +{"type":"emit_inbound","label":"cancel.tool.failed","frame":{"type":"sdk.event","event":{"type":"session.tool.failed","data":{"sessionID":"ses_opencode2_two_background_child_stop_cancel","assistantMessageID":"message_opencode2_two_background_child_stop_cancel","callID":"call_opencode2_two_background_child_stop_shell","error":{"type":"ToolExecutionError","message":"Tool execution interrupted"},"executed":true}}}} +{"type":"emit_inbound","label":"cancel.execution.interrupted","frame":{"type":"sdk.event","event":{"type":"session.execution.interrupted","data":{"sessionID":"ses_opencode2_two_background_child_stop_cancel","reason":"user"}}}} +{"type":"expect_outbound","label":"root.pending.list.after.stop","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_two_background_child_stop"}}} +{"type":"emit_inbound","label":"root.pending.list.after.stop.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list.after.stop","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-two-background-child-stop"}}}} +{"type":"emit_inbound","label":"root.shell.list.after.stop.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"emit_inbound","label":"wake.alpha.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_two_background_child_stop","inputID":"input_opencode2_two_background_child_stop_wake_alpha","input":{"type":"synthetic","data":{"text":"ALPHA_BACKGROUND_OK","description":"alpha background child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"wake.alpha.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_two_background_child_stop","inputID":"input_opencode2_two_background_child_stop_wake_alpha"}}}} +{"type":"emit_inbound","label":"wake.alpha.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_two_background_child_stop"}}}} +{"type":"emit_inbound","label":"wake.alpha.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_wake_alpha","ordinal":0}}}} +{"type":"emit_inbound","label":"wake.alpha.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_wake_alpha","ordinal":0,"delta":"ALPHA_BACKGROUND_OK"}}}} +{"type":"emit_inbound","label":"wake.alpha.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_wake_alpha","ordinal":0,"text":"ALPHA_BACKGROUND_OK"}}}} +{"type":"emit_inbound","label":"wake.alpha.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_two_background_child_stop"}}}} +{"type":"emit_inbound","label":"wake.cancel.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_two_background_child_stop","inputID":"input_opencode2_two_background_child_stop_wake_cancel","input":{"type":"synthetic","data":{"text":"CANCELLED_WAKE_OUTPUT_MUST_NOT_APPEAR","description":"cancel background child fixture"},"delivery":"queue"}}}}} +{"type":"emit_inbound","label":"wake.cancel.input.promoted","frame":{"type":"sdk.event","event":{"type":"session.input.promoted","data":{"sessionID":"ses_opencode2_two_background_child_stop","inputID":"input_opencode2_two_background_child_stop_wake_cancel"}}}} +{"type":"emit_inbound","label":"wake.cancel.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_two_background_child_stop"}}}} +{"type":"expect_outbound","label":"root.pending.list.continuation","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_two_background_child_stop"}}} +{"type":"emit_inbound","label":"root.pending.list.continuation.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"root.shell.list.continuation","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-two-background-child-stop"}}}} +{"type":"emit_inbound","label":"root.shell.list.continuation.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"emit_inbound","label":"wake.cancel.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_wake_cancel","ordinal":0}}}} +{"type":"emit_inbound","label":"wake.cancel.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_wake_cancel","ordinal":0,"delta":"CANCELLED_WAKE_OUTPUT_MUST_NOT_APPEAR"}}}} +{"type":"emit_inbound","label":"wake.cancel.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_two_background_child_stop","assistantMessageID":"message_opencode2_two_background_child_stop_wake_cancel","ordinal":0,"text":"CANCELLED_WAKE_OUTPUT_MUST_NOT_APPEAR"}}}} +{"type":"emit_inbound","label":"wake.cancel.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_two_background_child_stop"}}}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_stop/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_stop/output.ts new file mode 100644 index 00000000000..dcb15110803 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_two_background_child_stop/output.ts @@ -0,0 +1,77 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAssistantTextIncludes, + assertBaseProjection, + assertSemanticProjectionIntegrity, + projectionFor, +} from "../shared.ts"; + +export function assertOpenCode2TwoBackgroundChildStopOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + const alphaSettled = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "wake.alpha.execution.succeeded", + ); + const cancelStarted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "wake.cancel.execution.started", + ); + const continuationPendingList = transcript.entries.findIndex( + (entry) => entry.type === "expect_outbound" && entry.label === "root.pending.list.continuation", + ); + const cancelTextStarted = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "wake.cancel.text.started", + ); + const cancelSettled = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "wake.cancel.execution.succeeded", + ); + assert.isAtLeast(alphaSettled, 0); + assert.isAtLeast(cancelStarted, 0); + assert.isAtLeast(continuationPendingList, 0); + assert.isAtLeast(cancelTextStarted, 0); + assert.isAtLeast(cancelSettled, 0); + assert.isAbove(cancelStarted, alphaSettled); + assert.isAbove(continuationPendingList, cancelStarted); + assert.isAbove(cancelTextStarted, continuationPendingList); + assert.isAbove(cancelSettled, cancelTextStarted); + + const projection = projectionFor(result, transcript.scenario); + assertBaseProjection({ + result, + transcript, + runCount: 2, + runStatuses: ["completed", "completed"], + }); + assertSemanticProjectionIntegrity(projection); + assertAssistantTextIncludes(projection, "PARENT_RELEASED"); + assertAssistantTextIncludes(projection, "ALPHA_BACKGROUND_OK"); + assert.notInclude(JSON.stringify(projection), "CANCELLED_WAKE_OUTPUT_MUST_NOT_APPEAR"); + + const subagentItems = projection.turnItems.filter((item) => item.type === "subagent"); + assert.lengthOf(subagentItems, 2); + const alphaItem = subagentItems.find( + (item) => item.type === "subagent" && item.title?.includes("alpha"), + ); + const cancelItem = subagentItems.find( + (item) => item.type === "subagent" && item.title?.includes("cancel"), + ); + assert.strictEqual(alphaItem?.type, "subagent"); + assert.strictEqual(cancelItem?.type, "subagent"); + if (alphaItem?.type !== "subagent" || cancelItem?.type !== "subagent") { + throw new Error("OpenCode 2 two-child background Stop items are missing"); + } + assert.equal(alphaItem.status, "completed"); + assert.equal(cancelItem.status, "interrupted"); + assert.include(cancelItem.result ?? "", "CANCEL_PARTIAL"); + + const alphaProjection = result.projections.get(alphaItem.childThreadId!); + const cancelProjection = result.projections.get(cancelItem.childThreadId!); + assert.isDefined(alphaProjection); + assert.isDefined(cancelProjection); + assertAssistantTextIncludes(alphaProjection!, "ALPHA_BACKGROUND_OK"); + assertAssistantTextIncludes(cancelProjection!, "CANCEL_PARTIAL"); + assert.notInclude(JSON.stringify(cancelProjection), "CANCELLED_WAKE_OUTPUT_MUST_NOT_APPEAR"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/opencode2_transcript.ndjson new file mode 100644 index 00000000000..63fd9942684 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/opencode2_transcript.ndjson @@ -0,0 +1,31 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16383","scenario":"queued_turn","metadata":{"source":"derived-from-live-run","capturedAt":"2026-07-29","nativeSessionId":"ses_opencode2_queued_turn","model":"opencode/big-pickle","description":"An OpenCode 2 turn with a second app-owned message queued while the first turn is active. The provider sees the second prompt only after the first turn settles."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_queued_turn","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785297600000,"updated":1785297600000},"title":"T3 OpenCode 2 queued-turn replay","location":{"directory":"/private/tmp/t3-opencode2-replay"}}}} +{"type":"expect_outbound","label":"session.prompt.first","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_queued_turn","text":"Respond with exactly: first fixture turn complete"}}} +{"type":"emit_inbound","label":"session.prompt.first.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_first"}}} +{"type":"emit_inbound","label":"session.input.admitted.first","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_queued_turn","inputID":"input_opencode2_first","input":{"id":"input_opencode2_first","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started.first","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_queued_turn"}}}} +{"type":"emit_inbound","label":"session.text.started.first","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_queued_turn","assistantMessageID":"message_opencode2_first","ordinal":0}}}} +{"type":"emit_inbound","label":"session.text.delta.first","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_queued_turn","assistantMessageID":"message_opencode2_first","ordinal":0,"delta":"first fixture turn complete"}}}} +{"type":"emit_inbound","label":"session.text.ended.first","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_queued_turn","assistantMessageID":"message_opencode2_first","ordinal":0,"text":"first fixture turn complete"}}}} +{"type":"emit_inbound","label":"session.execution.succeeded.first","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_queued_turn"}}}} +{"type":"expect_outbound","label":"session.pending.list.first","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_queued_turn"}}} +{"type":"emit_inbound","label":"session.pending.list.first.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list.first","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-replay"}}}} +{"type":"emit_inbound","label":"shell.list.first.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"expect_outbound","label":"session.prompt.second","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_queued_turn","text":"Respond with exactly: second fixture turn complete"}}} +{"type":"emit_inbound","label":"session.prompt.second.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_second"}}} +{"type":"emit_inbound","label":"session.input.admitted.second","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_queued_turn","inputID":"input_opencode2_second","input":{"id":"input_opencode2_second","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started.second","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_queued_turn"}}}} +{"type":"emit_inbound","label":"session.text.started.second","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_queued_turn","assistantMessageID":"message_opencode2_second","ordinal":0}}}} +{"type":"emit_inbound","label":"session.text.delta.second","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_queued_turn","assistantMessageID":"message_opencode2_second","ordinal":0,"delta":"second fixture turn complete"}}}} +{"type":"emit_inbound","label":"session.text.ended.second","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_queued_turn","assistantMessageID":"message_opencode2_second","ordinal":0,"text":"second fixture turn complete"}}}} +{"type":"emit_inbound","label":"session.execution.succeeded.second","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_queued_turn"}}}} +{"type":"expect_outbound","label":"session.pending.list.second","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_queued_turn"}}} +{"type":"emit_inbound","label":"session.pending.list.second.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list.second","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-replay"}}}} +{"type":"emit_inbound","label":"shell.list.second.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"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 0a50aca4e5d..5cb2ca0ad5a 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -50,6 +50,29 @@ export const SUBAGENT_PROMPT = export const SUBAGENT_V2_PROMPT = "just say hello"; export const OPENCODE_SUBAGENT_PROMPT = "Use the task tool exactly once. Delegate to the general subagent with this prompt: Respond exactly CHILD_OK. After the task completes, respond exactly PARENT_OK."; +export const OPENCODE2_COMPACTION_PROMPT = + "Compact the current context, then respond exactly: compaction fixture complete"; +export const OPENCODE2_COMPACTION_INTERRUPT_PROMPT = + "Begin compacting the current context and wait for it to finish."; +export const OPENCODE2_RETRY_PROMPT = + "Recover from the transient provider error, then respond exactly: retry fixture complete"; +export const OPENCODE2_SUBAGENT_PROMPT = + "Use the subagent tool exactly once with description child fixture and prompt Respond exactly CHILD_OK. Then respond exactly PARENT_OK."; +export const OPENCODE2_SUBAGENT_BACKGROUND_PROMPT = + "Start one background subagent with description background child fixture and prompt Respond exactly CHILD_BACKGROUND_OK. Then respond exactly PARENT_RELEASED without waiting for the child."; +export const OPENCODE2_TWO_SUBAGENT_BACKGROUND_PROMPT = + "Start two background subagents: one with description alpha background child fixture and prompt Respond exactly ALPHA_BACKGROUND_OK, and one with description cancel background child fixture and prompt Respond exactly CANCEL_BACKGROUND_PARTIAL. Then respond exactly PARENT_RELEASED without waiting for either child."; +export const OPENCODE2_TWO_COMPLETED_SUBAGENT_PROMPT = + "Start two background subagents: one with description alpha completed child fixture and prompt Respond exactly ALPHA_COMPLETED_OK, and one with description bravo completed child fixture and prompt Respond exactly BRAVO_COMPLETED_OK. Then respond exactly PARENT_RELEASED without waiting for either child."; +export const OPENCODE2_THREAD_DELETE_PROMPT = "Respond exactly: native deletion fixture complete"; +export const OPENCODE2_SHELL_PROJECTION_PROMPT = + "Run a shell command that prints the paged shell fixture output, move it to background observation, then respond exactly: shell projection fixture complete"; +export const OPENCODE2_SHELL_FAILURE_PROMPT = + "Run a shell command that exits with status 7, move it to background observation, then respond exactly: shell failure fixture complete"; +export const OPENCODE2_SHELL_DELETION_PROMPT = + "Run a long shell command, move it to background observation, then respond exactly: shell deletion fixture complete"; +export const OPENCODE2_BACKGROUND_STOP_PROMPT = + "Run this shell command and wait for it: sleep 30 && echo background stop should not finish. Do not respond before it completes."; export const SUBAGENT_CONTINUE_PROMPT = "Spawn one subagent and have it reply exactly: initial subagent response"; export const SUBAGENT_CONTINUE_PARENT_PROMPT = @@ -163,6 +186,10 @@ export type OrchestratorFixtureInputStep = readonly text: string; readonly attachments?: ReadonlyArray; } + | { + readonly type: "provider_continuation"; + readonly text?: string; + } | { readonly type: "steer"; readonly text: string; @@ -180,6 +207,13 @@ export type OrchestratorFixtureInputStep = readonly targetRunIndex: number; readonly waitForTurnItemType?: OrchestrationV2TurnItem["type"]; } + | { + readonly type: "interrupt_provider_native"; + readonly subagentNativeItemId: string; + } + | { + readonly type: "delete"; + } | { readonly type: "approve_next_runtime_request"; readonly decision?: Extract< @@ -264,6 +298,11 @@ export const OPENCODE_MODEL_SELECTION = { options: [{ id: "agent", value: "build" }], } satisfies ModelSelection; +export const OPENCODE2_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("opencode2"), + model: "opencode/big-pickle", +} satisfies ModelSelection; + export const ACP_REGISTRY_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("acpRegistry"), model: "default", @@ -352,11 +391,19 @@ export function dispatchMessageCommand(input: { OrchestrationV2Command, { readonly type: "message.dispatch" } >["dispatchMode"]; + readonly createdBy?: Extract< + OrchestrationV2Command, + { readonly type: "message.dispatch" } + >["createdBy"]; + readonly creationSource?: Extract< + OrchestrationV2Command, + { readonly type: "message.dispatch" } + >["creationSource"]; }): OrchestrationV2Command { return { type: "message.dispatch", - createdBy: "user", - creationSource: "web", + createdBy: input.createdBy ?? "user", + creationSource: input.creationSource ?? "web", commandId: input.commandId, threadId: input.ids.threadId, messageId: input.messageId, @@ -435,6 +482,7 @@ export function materializeFixtureInput(input: { const shouldRunInBackground = (nextStep !== undefined && ((nextStep.type === "interrupt" && nextStep.targetRunIndex === runIndex) || + nextStep.type === "interrupt_provider_native" || nextStep.type === "queue_message" || (nextStep.type === "restart" && nextStep.targetRunIndex === runIndex))) || nextStep?.type === "approve_next_runtime_request" || @@ -459,6 +507,14 @@ export function materializeFixtureInput(input: { ); if (shouldRunInBackground) { activeRunDispatchKeys.add(key); + if (nextStep?.type === "interrupt_provider_native") { + steps.push({ + type: "await_run_status", + threadId: ids.threadId, + runId: runIdFor(runIndex), + status: "completed", + }); + } } else if ( !( nextStep !== undefined && @@ -470,6 +526,29 @@ export function materializeFixtureInput(input: { } } break; + case "provider_continuation": + messageIndex += 1; + runIndex += 1; + pushDispatch( + dispatchMessageCommand({ + commandId: yield* idAllocator.allocate.command({ + fixtureName: input.scenario, + commandName: `provider-continuation-${messageIndex}`, + }), + ids, + modelSelection: input.modelSelection, + messageId: yield* idAllocator.allocate.message({ + threadId: ids.threadId, + ordinal: messageIndex, + }), + text: step.text ?? "Background task completed.", + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "provider", + }), + ); + steps.push({ type: "await_thread_idle", threadId: ids.threadId }); + break; case "queue_message": messageIndex += 1; runIndex += 1; @@ -645,6 +724,40 @@ export function materializeFixtureInput(input: { steps.push({ type: "advance_clock", duration: "1 millis" }); steps.push({ type: "await_thread_idle", threadId: ids.threadId }); break; + case "interrupt_provider_native": + pushDispatch( + { + type: "run.interrupt", + commandId: yield* idAllocator.allocate.command({ + fixtureName: input.scenario, + commandName: "interrupt-provider-native", + }), + threadId: ids.threadId, + intent: "provider_native_only", + }, + { advanceClockAfter: false }, + ); + steps.push({ + type: "await_subagent_status", + threadId: ids.threadId, + status: "interrupted", + subagentId: idAllocator.derive.nodeFromProviderItem({ + driver: input.driver, + nativeItemId: step.subagentNativeItemId, + }), + }); + steps.push({ type: "advance_clock", duration: "1 millis" }); + break; + case "delete": + pushDispatch({ + type: "thread.delete", + commandId: yield* idAllocator.allocate.command({ + fixtureName: input.scenario, + commandName: "thread-delete", + }), + threadId: ids.threadId, + }); + break; case "advance_clock": steps.push({ type: "advance_clock", duration: step.duration }); break; diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/opencode2_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/simple/opencode2_transcript.ndjson new file mode 100644 index 00000000000..fe1df795d36 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/opencode2_transcript.ndjson @@ -0,0 +1,22 @@ +{"type":"transcript_start","provider":"opencode2","protocol":"opencode2-sdk.sse","version":"0.0.0-next-16553","scenario":"simple","metadata":{"source":"derived-from-installed-app-failure-boundary","capturedAt":"2026-07-30","nativeSessionId":"ses_opencode2_simple","model":"opencode/big-pickle","description":"A text-only OpenCode 2 turn that reproduces the preview legacy permission payload without patterns, requires a non-persistent full-access reply, and completes the post-settlement probes at the SDK request and SSE boundary."}} +{"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} +{"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":[{"id":"build"},{"id":"plan"}]}} +{"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"model":{"id":"big-pickle","providerID":"opencode"},"agent":"build","location":{"directory":""}}}} +{"type":"emit_inbound","label":"session.create.response","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_opencode2_simple","projectID":"global","model":{"id":"big-pickle","providerID":"opencode"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1785297600000,"updated":1785297600000},"title":"T3 OpenCode 2 replay","location":{"directory":"/private/tmp/t3-opencode2-replay"}}}} +{"type":"expect_outbound","label":"session.prompt","frame":{"type":"session.prompt","input":{"sessionID":"ses_opencode2_simple","text":"Respond with the following text: fixture simple ok"}}} +{"type":"emit_inbound","label":"session.prompt.response","frame":{"type":"sdk.response","operation":"session.prompt","data":{"id":"input_opencode2_simple"}}} +{"type":"emit_inbound","label":"session.input.admitted","frame":{"type":"sdk.event","event":{"type":"session.input.admitted","data":{"sessionID":"ses_opencode2_simple","inputID":"input_opencode2_simple","input":{"id":"input_opencode2_simple","type":"user"}}}}} +{"type":"emit_inbound","label":"session.execution.started","frame":{"type":"sdk.event","event":{"type":"session.execution.started","data":{"sessionID":"ses_opencode2_simple"}}}} +{"type":"emit_inbound","label":"permission.asked.without-patterns","frame":{"type":"sdk.event","event":{"type":"permission.asked","data":{"id":"permission_opencode2_simple","sessionID":"ses_opencode2_simple","permission":"bash","metadata":{},"always":[]}}}} +{"type":"expect_outbound","label":"session.permission.reply","frame":{"type":"session.permission.reply","input":{"sessionID":"ses_opencode2_simple","requestID":"permission_opencode2_simple","reply":"once"}}} +{"type":"emit_inbound","label":"session.permission.reply.response","frame":{"type":"sdk.response","operation":"session.permission.reply","data":null}} +{"type":"emit_inbound","label":"session.text.started","frame":{"type":"sdk.event","event":{"type":"session.text.started","data":{"sessionID":"ses_opencode2_simple","assistantMessageID":"message_opencode2_simple","ordinal":0}}}} +{"type":"emit_inbound","label":"session.text.delta","frame":{"type":"sdk.event","event":{"type":"session.text.delta","data":{"sessionID":"ses_opencode2_simple","assistantMessageID":"message_opencode2_simple","ordinal":0,"delta":"fixture simple ok"}}}} +{"type":"emit_inbound","label":"session.text.ended","frame":{"type":"sdk.event","event":{"type":"session.text.ended","data":{"sessionID":"ses_opencode2_simple","assistantMessageID":"message_opencode2_simple","ordinal":0,"text":"fixture simple ok"}}}} +{"type":"emit_inbound","label":"session.execution.succeeded","frame":{"type":"sdk.event","event":{"type":"session.execution.succeeded","data":{"sessionID":"ses_opencode2_simple"}}}} +{"type":"expect_outbound","label":"session.pending.list","frame":{"type":"session.pending.list","input":{"sessionID":"ses_opencode2_simple"}}} +{"type":"emit_inbound","label":"session.pending.list.response","frame":{"type":"sdk.response","operation":"session.pending.list","data":[]}} +{"type":"expect_outbound","label":"shell.list","frame":{"type":"shell.list","input":{"location":{"directory":"/private/tmp/t3-opencode2-replay"}}}} +{"type":"emit_inbound","label":"shell.list.response","frame":{"type":"sdk.response","operation":"shell.list","data":[]}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/provider/Drivers/OpenCode2Driver.test.ts b/apps/server/src/provider/Drivers/OpenCode2Driver.test.ts new file mode 100644 index 00000000000..40ad6785e79 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenCode2Driver.test.ts @@ -0,0 +1,86 @@ +import { expect, it } from "@effect/vitest"; + +import { openCode2ProviderMaintenanceResolver } from "./OpenCode2Driver.ts"; + +const provider = "opencode2"; +const packageName = "@opencode-ai/cli"; + +it.each([ + { + manager: "npm", + commandPath: "/usr/local/lib/node_modules/@opencode-ai/cli/bin/opencode2.exe", + command: "npm install -g @opencode-ai/cli@next", + executable: "npm", + args: ["install", "-g", "@opencode-ai/cli@next"], + lockKey: "npm-global", + }, + { + manager: "Bun", + commandPath: "/home/test/.bun/bin/opencode2", + command: "bun add -g --trust @opencode-ai/cli@next", + executable: "bun", + args: ["add", "-g", "--trust", "@opencode-ai/cli@next"], + lockKey: "bun-global", + }, + { + manager: "pnpm", + commandPath: "/home/test/.local/share/pnpm/opencode2", + command: "pnpm add -g @opencode-ai/cli@next --allow-build=@opencode-ai/cli", + executable: "pnpm", + args: ["add", "-g", "@opencode-ai/cli@next", "--allow-build=@opencode-ai/cli"], + lockKey: "pnpm-global", + }, + { + manager: "Vite Plus", + commandPath: "/home/test/.vite-plus/bin/opencode2", + command: "vp i -g @opencode-ai/cli@next", + executable: "vp", + args: ["i", "-g", "@opencode-ai/cli@next"], + lockKey: "vite-plus-global", + }, +])("uses $manager to update a package-managed opencode2 executable", (expected) => { + const capabilities = openCode2ProviderMaintenanceResolver({ serverUrl: "" }).resolve({ + binaryPath: "opencode2", + resolvedCommandPath: expected.commandPath, + }); + + expect(capabilities).toEqual({ + provider, + packageName, + npmDistTag: "next", + update: { + command: expected.command, + executable: expected.executable, + args: expected.args, + lockKey: expected.lockKey, + }, + }); +}); + +it("keeps custom OpenCode 2 executable paths manual-only", () => { + expect( + openCode2ProviderMaintenanceResolver({ serverUrl: "" }).resolve({ + binaryPath: "/opt/opencode-dev/opencode2", + resolvedCommandPath: "/opt/opencode-dev/opencode2", + }), + ).toEqual({ + provider, + packageName, + npmDistTag: "next", + update: null, + }); +}); + +it("keeps externally managed OpenCode 2 servers manual-only", () => { + expect( + openCode2ProviderMaintenanceResolver({ serverUrl: "http://127.0.0.1:4096" }).resolve({ + binaryPath: "opencode2", + resolvedCommandPath: "/home/test/.bun/bin/opencode2", + }), + ).toEqual({ + provider, + packageName, + npmDistTag: "next", + update: null, + }); +}); diff --git a/apps/server/src/provider/Drivers/OpenCode2Driver.ts b/apps/server/src/provider/Drivers/OpenCode2Driver.ts new file mode 100644 index 00000000000..2697ecbf645 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenCode2Driver.ts @@ -0,0 +1,222 @@ +/** + * OpenCode2Driver — `ProviderDriver` for the OpenCode 2.x runtime. + * + * Separate from `OpenCodeDriver` rather than a binary-path variant of it: 2.x + * serves a different route surface, mints a mandatory server password, and + * emits a different event vocabulary, so the two share no runtime, no adapter, + * and no probe. See `OpenCode2AdapterV2` for the protocol contract. + * + * The driver ships with `hasDefaultInstance: false` (see + * `apps/web/src/components/settings/providerDriverMeta.ts`), so there is no + * built-in instance and no `providers.opencode2`-backed default: an instance + * exists only when the user adds one. + * + * @module provider/Drivers/OpenCode2Driver + */ +import { OpenCode2Settings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { + OpenCode2AdapterV2Driver, + type OpenCode2AdapterV2DriverEnv, +} from "../../orchestration-v2/Adapters/OpenCode2AdapterV2.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeOpenCode2TextGeneration } from "../../textGeneration/OpenCode2TextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { + checkOpenCode2ProviderStatus, + makePendingOpenCode2Provider, +} from "../Layers/OpenCode2Provider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { applyOpenCode2ProviderEnvironment } from "../OpenCode2ProviderEnvironment.ts"; +import { OpenCode2Runtime } from "../opencode2Runtime.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + makeManualOnlyProviderMaintenanceCapabilities, + makePackageManagedProviderMaintenanceResolver, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, + type ProviderMaintenanceCapabilitiesResolver, +} from "../providerMaintenance.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeOpenCode2Settings = Schema.decodeSync(OpenCode2Settings); + +const DRIVER_KIND = ProviderDriverKind.make("opencode2"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const NPM_DIST_TAG = "next"; +const NPM_PACKAGE_NAME = "@opencode-ai/cli"; +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: NPM_PACKAGE_NAME, + npmDistTag: NPM_DIST_TAG, + requiresInstallScripts: true, + homebrewFormula: null, + nativeUpdate: null, +}); + +export function openCode2ProviderMaintenanceResolver( + settings: Pick, +): ProviderMaintenanceCapabilitiesResolver { + return settings.serverUrl + ? makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: NPM_PACKAGE_NAME, + npmDistTag: NPM_DIST_TAG, + }), + ) + : UPDATE; +} + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export type OpenCode2DriverEnv = + | OpenCode2AdapterV2DriverEnv + | BackgroundPolicy.BackgroundPolicy + | FileSystem.FileSystem + | HttpClient.HttpClient + | OpenCode2Runtime + | Path.Path + | ServerConfig + | ServerSettingsService; + +export const OpenCode2Driver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "OpenCode 2", + supportsMultipleInstances: true, + }, + configSchema: OpenCode2Settings, + defaultConfig: (): OpenCode2Settings => decodeOpenCode2Settings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const openCode2Runtime = yield* OpenCode2Runtime; + const serverConfig = yield* ServerConfig; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const effectiveConfig = { ...config, enabled } satisfies OpenCode2Settings; + const processEnv = applyOpenCode2ProviderEnvironment( + effectiveConfig, + mergeProviderInstanceEnvironment(environment), + ); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + openCode2ProviderMaintenanceResolver(effectiveConfig), + { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }, + ); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + + const orchestrationAdapter = yield* OpenCode2AdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build OpenCode 2 orchestration adapter.", + cause, + }), + ), + ); + const textGeneration = yield* makeOpenCode2TextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkOpenCode2ProviderStatus( + effectiveConfig, + serverConfig.cwd, + processEnv, + ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCode2Runtime, openCode2Runtime)); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + makePendingOpenCode2Provider(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build OpenCode 2 snapshot.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/OpenCode2Provider.agentSelection.test.ts b/apps/server/src/provider/Layers/OpenCode2Provider.agentSelection.test.ts new file mode 100644 index 00000000000..7499657fadc --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCode2Provider.agentSelection.test.ts @@ -0,0 +1,109 @@ +import type { AgentInfoV2, ModelInfo } from "@opencode-ai/sdk-next/v2"; +import { describe, expect, it } from "vite-plus/test"; + +import { flattenOpenCode2Models } from "./OpenCode2Provider.ts"; + +const MODEL = { + id: "glm-5.2", + modelID: "glm-5.2", + providerID: "opencode", + name: "GLM-5.2", + capabilities: { + tools: true, + input: ["text"], + output: ["text"], + }, + variants: [], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { + context: 128_000, + output: 16_384, + }, +} satisfies ModelInfo; + +const BUILD_AGENT = { + id: "build", + name: "Build", + request: { settings: {}, headers: {}, body: {} }, + mode: "primary", + hidden: false, + permissions: [], +} satisfies AgentInfoV2; + +const PLAN_AGENT = { ...BUILD_AGENT, id: "plan", name: "Plan" } satisfies AgentInfoV2; + +describe("OpenCode 2 agent inventory", () => { + // The Build/Plan interaction-mode toggle owns the native pair, so no Agent + // descriptor appears unless custom primary agents exist. + it("suppresses the agent descriptor when only build and plan exist", () => { + const [model] = flattenOpenCode2Models({ + models: [MODEL], + agents: [BUILD_AGENT, PLAN_AGENT], + }); + + expect( + model?.capabilities?.optionDescriptors?.some((candidate) => candidate.id === "agent"), + ).toBe(false); + }); + + it("suppresses the agent descriptor when the native pair is incomplete", () => { + const customAgent = { + ...BUILD_AGENT, + id: "release-captain", + name: "Release Captain", + } satisfies AgentInfoV2; + const [model] = flattenOpenCode2Models({ + models: [MODEL], + agents: [BUILD_AGENT, customAgent], + }); + expect( + model?.capabilities?.optionDescriptors?.some((candidate) => candidate.id === "agent"), + ).toBe(false); + }); + + it("suppresses the agent descriptor when only plan and a custom agent exist", () => { + const customAgent = { + ...BUILD_AGENT, + id: "release-captain", + name: "Release Captain", + } satisfies AgentInfoV2; + const [model] = flattenOpenCode2Models({ + models: [MODEL], + agents: [PLAN_AGENT, customAgent], + }); + expect( + model?.capabilities?.optionDescriptors?.some((candidate) => candidate.id === "agent"), + ).toBe(false); + }); + + it("keeps executable agent ids separate from title-cased labels", () => { + const customAgent = { + ...BUILD_AGENT, + id: "Release-Captain", + name: "Release Captain", + } satisfies AgentInfoV2; + const [model] = flattenOpenCode2Models({ + models: [MODEL], + agents: [customAgent, BUILD_AGENT, PLAN_AGENT], + }); + const descriptor = model?.capabilities?.optionDescriptors?.find( + (candidate) => candidate.id === "agent", + ); + + // Custom agents ride behind the Auto sentinel; build/plan belong to the + // Build/Plan toggle and leave the submenu entirely. + expect(descriptor).toEqual({ + id: "agent", + label: "Agent", + type: "select", + currentValue: "auto", + options: [ + { id: "auto", label: "Auto (Build/Plan)" }, + { id: "Release-Captain", label: "Release Captain" }, + ], + }); + }); +}); diff --git a/apps/server/src/provider/Layers/OpenCode2Provider.test.ts b/apps/server/src/provider/Layers/OpenCode2Provider.test.ts new file mode 100644 index 00000000000..9da76730a4f --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCode2Provider.test.ts @@ -0,0 +1,629 @@ +import { assert, it } from "@effect/vitest"; +import { OpenCode2Settings } from "@t3tools/contracts"; +import type { + AgentInfoV2, + IntegrationInfo, + ModelInfo, + OpencodeClient, +} from "@opencode-ai/sdk-next/v2"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { describe } from "vite-plus/test"; + +import * as OpenCode2Runtime from "../opencode2Runtime.ts"; +import { parseGenericCliVersion } from "../providerSnapshot.ts"; +import { + checkOpenCode2ProviderStatus, + flattenOpenCode2Models, + isOpenCode2InventorySettlementError, + openCode2NextBuild, + parseOpenCode2Version, + settleOpenCode2Inventory, +} from "./OpenCode2Provider.ts"; + +const OPENCODE2_BANNER = "opencode2 v0.0.0-next-16339\n"; +const BIG_PICKLE_MODEL = { + id: "big-pickle", + modelID: "big-pickle", + providerID: "opencode", + name: "Big Pickle", + capabilities: { + tools: true, + input: ["text"], + output: ["text"], + }, + variants: [], + time: { + released: 0, + }, + cost: [], + status: "active", + enabled: true, + limit: { + context: 128_000, + output: 16_384, + }, +} satisfies ModelInfo; +const BIG_PICKLE_FAST_MODEL = { + ...BIG_PICKLE_MODEL, + id: "big-pickle-fast", + name: "Big Pickle Fast", +} satisfies ModelInfo; +const OPENAI_MODEL = { + ...BIG_PICKLE_MODEL, + id: "gpt-test", + modelID: "gpt-test", + providerID: "openai", + name: "GPT Test", +} satisfies ModelInfo; +const OPENCODE2_TEST_SETTINGS = Schema.decodeSync(OpenCode2Settings)({ + binaryPath: "fake-opencode2", +}); +const OPENCODE2_EXTERNAL_TEST_SETTINGS = Schema.decodeSync(OpenCode2Settings)({ + binaryPath: "fake-opencode2", + serverPassword: "external-secret", + serverUrl: "http://127.0.0.1:9998", +}); + +function failingOpenCode2Runtime( + category: OpenCode2Runtime.OpenCode2RuntimeErrorCategory, + cause?: unknown, +): OpenCode2Runtime.OpenCode2Runtime["Service"] { + const failure = new OpenCode2Runtime.OpenCode2RuntimeError({ + operation: "startOpenCode2ServerProcess", + category, + cause, + }); + return OpenCode2Runtime.OpenCode2Runtime.of({ + startOpenCode2ServerProcess: () => Effect.fail(failure), + connectToOpenCode2Server: () => Effect.fail(failure), + createOpenCode2SdkClient: () => { + throw new Error("unexpected SDK client creation"); + }, + }); +} + +function openCode2RuntimeWithHealthVersion( + version: string, + models: () => Array = () => [BIG_PICKLE_MODEL], +): OpenCode2Runtime.OpenCode2Runtime["Service"] { + const client = { + v2: { + agent: { + list: async () => ({ data: { data: [BUILD_AGENT] } }), + }, + health: { + get: async () => ({ data: { version } }), + }, + integration: { + list: async () => ({ + data: { + data: [ + { + id: "opencode", + name: "OpenCode", + methods: [], + connections: [{ type: "env", name: "OPENCODE_TEST_KEY" }], + } satisfies IntegrationInfo, + ], + }, + }), + }, + model: { + list: async () => ({ data: { data: models() } }), + }, + }, + } as unknown as OpencodeClient; + + return OpenCode2Runtime.OpenCode2Runtime.of({ + startOpenCode2ServerProcess: () => Effect.die("unexpected server process start"), + connectToOpenCode2Server: () => + Effect.succeed({ + exitCode: null, + external: false, + password: "test-password", + url: "http://127.0.0.1:1234", + }), + createOpenCode2SdkClient: () => client, + }); +} + +const BUILD_AGENT = { + id: "build", + name: "Build", + request: { settings: {}, headers: {}, body: {} }, + mode: "primary", + hidden: false, + permissions: [], +} satisfies AgentInfoV2; + +describe("parseOpenCode2Version", () => { + // The reason this parser exists: the generic one anchors on `\b`, and the + // `v` prefix kills the word boundary before the leading digit. + it("parses the banner the generic CLI parser returns null for", () => { + assert.strictEqual(parseGenericCliVersion(OPENCODE2_BANNER), null); + assert.strictEqual(parseOpenCode2Version(OPENCODE2_BANNER), "0.0.0-next-16339"); + }); + + it("parses a plain release version", () => { + assert.strictEqual(parseOpenCode2Version("opencode2 2.1.4\n"), "2.1.4"); + }); + + it("returns null when there is no version at all", () => { + assert.strictEqual( + parseOpenCode2Version("Error: @opencode-ai/cli's postinstall script was not run."), + null, + ); + }); +}); + +describe("openCode2NextBuild", () => { + it("reads the build number off the next line", () => { + assert.strictEqual(openCode2NextBuild("0.0.0-next-16339"), 16339); + }); + + // A stable 2.x is not on the preview line, so the build gate must not apply + // to it rather than rejecting it for lacking a build number. + it("returns null for a version that is not on the next line", () => { + assert.strictEqual(openCode2NextBuild("2.1.4"), null); + assert.strictEqual(openCode2NextBuild("2.1.4-rc.1"), null); + }); +}); + +describe("checkOpenCode2ProviderStatus", () => { + it.effect("rejects local next builds below the verified floor with install guidance", () => + Effect.gen(function* () { + const providerFiber = yield* checkOpenCode2ProviderStatus( + OPENCODE2_TEST_SETTINGS, + "/workspace", + {}, + ).pipe( + Effect.provideService( + OpenCode2Runtime.OpenCode2Runtime, + openCode2RuntimeWithHealthVersion("0.0.0-next-10000"), + ), + Effect.forkChild, + ); + + yield* Effect.yieldNow; + yield* TestClock.adjust("500 millis"); + const provider = yield* Fiber.join(providerFiber); + + assert.strictEqual(provider.status, "error"); + assert.include(provider.message ?? "", "next-16339"); + assert.include(provider.message ?? "", "npm install"); + }), + ); + + it.effect("rejects external next builds below the verified floor with server guidance", () => + Effect.gen(function* () { + const providerFiber = yield* checkOpenCode2ProviderStatus( + OPENCODE2_EXTERNAL_TEST_SETTINGS, + "/workspace", + {}, + ).pipe( + Effect.provideService( + OpenCode2Runtime.OpenCode2Runtime, + openCode2RuntimeWithHealthVersion("0.0.0-next-10000"), + ), + Effect.forkChild, + ); + + yield* Effect.yieldNow; + yield* TestClock.adjust("500 millis"); + const provider = yield* Fiber.join(providerFiber); + + assert.strictEqual(provider.status, "error"); + assert.include(provider.message ?? "", "configured OpenCode 2 server"); + assert.include(provider.message ?? "", "next-16339"); + assert.notInclude(provider.message ?? "", "npm install"); + }), + ); + + it.effect("does not include a minted startup password in provider status", () => + Effect.gen(function* () { + const password = "MINTED_PROVIDER_STATUS_PASSWORD"; + const runtime = failingOpenCode2Runtime("startup-failed", new Error(password)); + const provider = yield* checkOpenCode2ProviderStatus( + OPENCODE2_TEST_SETTINGS, + "/workspace", + {}, + ).pipe(Effect.provideService(OpenCode2Runtime.OpenCode2Runtime, runtime)); + + assert.notInclude(provider.message ?? "", password); + assert.include(provider.message ?? "", "startup-failed"); + }), + ); + + it.effect("preserves safe package and executable diagnostics", () => + Effect.gen(function* () { + const placeholder = yield* checkOpenCode2ProviderStatus( + OPENCODE2_TEST_SETTINGS, + "/workspace", + {}, + ).pipe( + Effect.provideService( + OpenCode2Runtime.OpenCode2Runtime, + failingOpenCode2Runtime("placeholder-binary"), + ), + ); + const missing = yield* checkOpenCode2ProviderStatus( + OPENCODE2_TEST_SETTINGS, + "/workspace", + {}, + ).pipe( + Effect.provideService( + OpenCode2Runtime.OpenCode2Runtime, + failingOpenCode2Runtime("binary-not-found"), + ), + ); + + assert.isFalse(placeholder.installed); + assert.include(placeholder.message ?? "", "postinstall script never ran"); + assert.isFalse(missing.installed); + assert.include(missing.message ?? "", "not installed or not on PATH"); + }), + ); + + it.effect("preserves generic health version diagnostics", () => + Effect.gen(function* () { + const providerFiber = yield* checkOpenCode2ProviderStatus( + OPENCODE2_TEST_SETTINGS, + "/workspace", + {}, + ).pipe( + Effect.provideService( + OpenCode2Runtime.OpenCode2Runtime, + openCode2RuntimeWithHealthVersion("not-a-version"), + ), + Effect.forkChild, + ); + + yield* Effect.yieldNow; + yield* TestClock.adjust("500 millis"); + const provider = yield* Fiber.join(providerFiber); + + assert.include( + provider.message ?? "", + "Unable to determine OpenCode 2 version from `/api/health`.", + ); + }), + ); + + it.effect("reports inventory instability without blaming the health check", () => + Effect.gen(function* () { + let reads = 0; + const providerFiber = yield* checkOpenCode2ProviderStatus( + OPENCODE2_TEST_SETTINGS, + "/workspace", + {}, + ).pipe( + Effect.provideService( + OpenCode2Runtime.OpenCode2Runtime, + openCode2RuntimeWithHealthVersion("0.0.0-next-16339", () => { + reads += 1; + return [{ ...BIG_PICKLE_MODEL, id: `big-pickle-${reads}` }]; + }), + ), + Effect.forkChild, + ); + + yield* Effect.yieldNow; + yield* TestClock.adjust("6 seconds"); + const provider = yield* Fiber.join(providerFiber); + + assert.strictEqual(provider.status, "error"); + assert.include(provider.message ?? "", "inventory did not stabilize"); + assert.notInclude(provider.message ?? "", "health check"); + }), + ); +}); + +describe("settleOpenCode2Inventory", () => { + it.effect("uses a 500ms healthy-path floor by default", () => + Effect.gen(function* () { + const reads = yield* Ref.make(0); + const settlement = yield* settleOpenCode2Inventory( + Ref.update(reads, (count) => count + 1).pipe( + Effect.as({ + models: [BIG_PICKLE_MODEL], + agents: [BUILD_AGENT], + connectedIntegrationIDs: ["opencode"], + }), + ), + ).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("499 millis"); + assert.strictEqual(yield* Ref.get(reads), 5); + + yield* TestClock.adjust("1 millis"); + yield* Fiber.join(settlement); + assert.strictEqual(yield* Ref.get(reads), 6); + }), + ); + + it.effect("waits through a non-empty baseline until connected integrations settle", () => + Effect.gen(function* () { + let reads = 0; + const inventory = yield* settleOpenCode2Inventory( + Effect.sync(() => { + reads += 1; + return reads < 4 + ? { models: [BIG_PICKLE_MODEL], agents: [BUILD_AGENT], connectedIntegrationIDs: [] } + : { + models: [BIG_PICKLE_MODEL, OPENAI_MODEL], + agents: [BUILD_AGENT], + connectedIntegrationIDs: ["openai", "opencode"], + }; + }), + { maxAttempts: 6, minimumAttempts: 4, quietAttempts: 2, retryDelayMs: 0 }, + ); + + assert.strictEqual(reads, 5); + assert.deepStrictEqual(inventory.connectedIntegrationIDs, ["openai", "opencode"]); + assert.deepStrictEqual( + inventory.models.map((model) => model.providerID), + ["opencode", "openai"], + ); + }), + ); + + it.effect("returns a logged-out free catalog only at the bounded deadline", () => + Effect.gen(function* () { + let reads = 0; + const inventory = yield* settleOpenCode2Inventory( + Effect.sync(() => { + reads += 1; + return { + models: [BIG_PICKLE_MODEL], + agents: [BUILD_AGENT], + connectedIntegrationIDs: [], + }; + }), + { maxAttempts: 6, minimumAttempts: 4, quietAttempts: 2, retryDelayMs: 0 }, + ); + + assert.strictEqual(reads, 6); + assert.deepStrictEqual(inventory.models, [BIG_PICKLE_MODEL]); + assert.deepStrictEqual(inventory.connectedIntegrationIDs, []); + }), + ); + + it.effect("keeps the last settled catalog when the final attempt changes", () => + Effect.gen(function* () { + let reads = 0; + const inventory = yield* settleOpenCode2Inventory( + Effect.sync(() => { + reads += 1; + return reads < 6 + ? { models: [BIG_PICKLE_MODEL], agents: [BUILD_AGENT], connectedIntegrationIDs: [] } + : { + models: [BIG_PICKLE_MODEL, OPENAI_MODEL], + agents: [BUILD_AGENT], + connectedIntegrationIDs: ["openai", "opencode"], + }; + }), + { maxAttempts: 6, minimumAttempts: 4, quietAttempts: 2, retryDelayMs: 0 }, + ); + + assert.strictEqual(reads, 6); + assert.deepStrictEqual(inventory.connectedIntegrationIDs, []); + assert.deepStrictEqual( + inventory.models.map((model) => model.providerID), + ["opencode"], + ); + }), + ); + + it.effect("fails when no catalog fingerprint stabilizes", () => + Effect.gen(function* () { + let reads = 0; + const error = yield* settleOpenCode2Inventory( + Effect.sync(() => { + reads += 1; + return { + models: [{ ...BIG_PICKLE_MODEL, id: `big-pickle-${reads}` }], + agents: [BUILD_AGENT], + connectedIntegrationIDs: [], + }; + }), + { maxAttempts: 6, minimumAttempts: 4, quietAttempts: 2, retryDelayMs: 0 }, + ).pipe(Effect.flip); + + assert.strictEqual(reads, 6); + assert.ok(isOpenCode2InventorySettlementError(error)); + assert.strictEqual(error.attempts, 6); + assert.strictEqual( + error.message, + "OpenCode 2 inventory did not stabilize before the retry limit.", + ); + }), + ); + + it.effect("ignores a non-model integration when another connection supplies models", () => + Effect.gen(function* () { + let reads = 0; + const inventory = yield* settleOpenCode2Inventory( + Effect.sync(() => { + reads += 1; + return { + models: [BIG_PICKLE_MODEL], + agents: [BUILD_AGENT], + connectedIntegrationIDs: ["openai", "opencode"], + }; + }), + { maxAttempts: 3, minimumAttempts: 2, quietAttempts: 2, retryDelayMs: 0 }, + ); + + assert.strictEqual(reads, 2); + assert.deepStrictEqual(inventory.connectedIntegrationIDs, ["openai", "opencode"]); + }), + ); + + it.effect("stops at the deadline when no connected integration supplies models", () => + Effect.gen(function* () { + let reads = 0; + const inventory = yield* settleOpenCode2Inventory( + Effect.sync(() => { + reads += 1; + return { + models: [BIG_PICKLE_MODEL], + agents: [BUILD_AGENT], + connectedIntegrationIDs: ["openai"], + }; + }), + { maxAttempts: 3, minimumAttempts: 2, quietAttempts: 2, retryDelayMs: 0 }, + ); + + assert.strictEqual(reads, 3); + assert.deepStrictEqual(inventory.connectedIntegrationIDs, ["openai"]); + }), + ); + + it.effect("stops at the deadline when the catalog stays empty", () => + Effect.gen(function* () { + let reads = 0; + const inventory = yield* settleOpenCode2Inventory( + Effect.sync(() => { + reads += 1; + return { models: [], agents: [], connectedIntegrationIDs: [] }; + }), + { maxAttempts: 3, retryDelayMs: 0 }, + ); + + assert.strictEqual(reads, 3); + assert.deepStrictEqual(inventory, { + models: [], + agents: [], + connectedIntegrationIDs: [], + }); + }), + ); +}); + +describe("flattenOpenCode2Models", () => { + it("uses a readable upstream provider label", () => { + assert.deepStrictEqual(flattenOpenCode2Models({ models: [BIG_PICKLE_MODEL], agents: [] }), [ + { + slug: "opencode/big-pickle", + name: "Big Pickle", + subProvider: "OpenCode", + isCustom: false, + capabilities: { + optionDescriptors: [], + }, + }, + ]); + }); + + it("uses the selectable model ref id when models share an underlying model id", () => { + assert.deepStrictEqual( + flattenOpenCode2Models({ + models: [BIG_PICKLE_MODEL, BIG_PICKLE_FAST_MODEL], + agents: [], + }).map((model) => model.slug), + ["opencode/big-pickle", "opencode/big-pickle-fast"], + ); + }); + + it("keeps a structured model whose id contains a slash", () => { + const slashModel = { + ...BIG_PICKLE_MODEL, + id: "qwen/qwen3-coder", + modelID: "qwen/qwen3-coder", + providerID: "openrouter", + name: "qwen3-coder", + } satisfies ModelInfo; + + // Unlike the 1.x text parser fixed by #5072 opencode-model-slug-misclassification, + // 2.x receives a structured SDK model and constructs the selectable + // provider/model ref directly. + assert.deepStrictEqual( + flattenOpenCode2Models({ models: [slashModel], agents: [] }).map((model) => model.slug), + ["openrouter/qwen/qwen3-coder"], + ); + }); + + it("marks the inferred reasoning default without a synthetic Default option", () => { + const [model] = flattenOpenCode2Models({ + models: [ + { + ...BIG_PICKLE_MODEL, + variants: [ + { id: "low" }, + { id: "medium" }, + { id: "high" }, + { id: "xhigh" }, + { id: "max" }, + ], + }, + ], + agents: [], + }); + + assert.deepStrictEqual(model?.capabilities?.optionDescriptors, [ + { + id: "variant", + label: "Reasoning", + type: "select", + currentValue: "medium", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + { id: "max", label: "Max" }, + ], + }, + ]); + }); + + it("hides a catalog-supplied Default sentinel", () => { + const [model] = flattenOpenCode2Models({ + models: [ + { + ...BIG_PICKLE_MODEL, + variants: [{ id: "default" }, { id: "high" }], + }, + ], + agents: [], + }); + const descriptor = model?.capabilities?.optionDescriptors?.find( + (candidate) => candidate.id === "variant", + ); + + assert.deepStrictEqual(descriptor?.type === "select" ? descriptor.options : [], [ + { id: "high", label: "High", isDefault: true }, + ]); + }); + + it("chooses a concrete fallback default for a thinking toggle", () => { + const [model] = flattenOpenCode2Models({ + models: [ + { + ...BIG_PICKLE_MODEL, + variants: [{ id: "none" }, { id: "thinking" }], + }, + ], + agents: [], + }); + const descriptor = model?.capabilities?.optionDescriptors?.find( + (candidate) => candidate.id === "variant", + ); + + assert.deepStrictEqual(descriptor, { + id: "variant", + label: "Reasoning", + type: "select", + currentValue: "thinking", + options: [ + { id: "none", label: "None" }, + { id: "thinking", label: "Thinking", isDefault: true }, + ], + }); + }); +}); diff --git a/apps/server/src/provider/Layers/OpenCode2Provider.ts b/apps/server/src/provider/Layers/OpenCode2Provider.ts new file mode 100644 index 00000000000..a5ad4a79d0f --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCode2Provider.ts @@ -0,0 +1,618 @@ +/** + * Provider status probe for OpenCode 2.x. + * + * 1.x's probe cannot be reused. It parses ` --version` with + * `parseGenericCliVersion` and enumerates models with ` models + * --verbose` / ` agent list`, and 2.x breaks both: + * + * - `/api/health` reports the running server's version. Reading it beside + * inventory avoids launching the binary a second time for `--version`. + * - the inventory subcommands are gone. 2.x's default handler treats the + * first argument as a directory to `chdir` into, logs `ENOENT`, and exits + * 0, so the probe cannot even detect its own failure by exit code. + * Inventory moved to `/api/model` and `/api/agent`. + * + * @module provider/Layers/OpenCode2Provider + */ +import type { AgentInfoV2, IntegrationInfo, ModelInfo } from "@opencode-ai/sdk-next/v2"; +import { + type ModelCapabilities, + type OpenCode2Settings, + type ServerProviderModel, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { createModelCapabilities } from "@t3tools/shared/model"; +import { + OPENCODE2_AUTO_AGENT, + OPENCODE2_DEFAULT_VARIANT, + isOpenCode2RuntimeError, + OpenCode2Runtime, + OpenCode2RuntimeError, + runOpenCode2Sdk, +} from "../opencode2Runtime.ts"; +import { + buildServerProvider, + inferOpenCodeDefaultVariant, + nonEmptyTrimmed, + providerModelsFromSettings, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +// The Build/Plan toggle maps onto opencode2's native `build`/`plan` primary +// agents (resolveOpenCode2SessionAgent in the adapter), so the toggle shows +// and the Agent descriptor is suppressed unless custom primary agents exist. +const OPENCODE2_PRESENTATION = { + displayName: "OpenCode 2", + showInteractionModeToggle: true, +} as const; + +/** + * The `next` build this driver's runtime, event mapping, and route usage were + * verified against. 2.x has no meaningful semver axis yet — every build on the + * line is `0.0.0-next-` — so the build number is the only ordering that + * carries information, and it is only compared when the version still carries + * a `next` tag. A future stable 2.x is accepted as-is rather than rejected by + * a rule written for the preview line. + */ +const MINIMUM_OPENCODE2_NEXT_BUILD = 16339; + +/** + * Accepts both the plain `/api/health` value and the `v` prefix emitted by the + * CLI, which `parseGenericCliVersion` chokes on. + * + * @internal exported for tests + */ +export function parseOpenCode2Version(output: string): string | null { + return output.match(/v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)/)?.[1] ?? null; +} + +/** + * Build number out of a `0.0.0-next-16339` style version, or `null` when the + * version is not on the `next` line and the build gate does not apply. + * + * @internal exported for tests + */ +export function openCode2NextBuild(version: string): number | null { + const match = version.match(/-next[.-](\d+)/); + if (!match) return null; + const build = Number(match[1]); + return Number.isFinite(build) ? build : null; +} + +function normalizeProbeMessage(message: string): string | undefined { + const trimmed = message.trim(); + if (trimmed.length === 0) return undefined; + if ( + trimmed === "An error occurred in Effect.tryPromise" || + trimmed === "An error occurred in Effect.try" + ) { + return undefined; + } + return trimmed; +} + +function normalizedErrorMessage(cause: unknown): string | undefined { + if (!(cause instanceof Error)) return undefined; + return normalizeProbeMessage(cause.message); +} + +function formatOpenCode2ProbeError(input: { + readonly cause: unknown; + readonly isExternalServer: boolean; + readonly serverUrl: string; +}): { readonly installed: boolean; readonly message: string } { + if (isOpenCode2InventorySettlementError(input.cause)) { + return { + installed: true, + message: "OpenCode 2 inventory did not stabilize before the retry limit.", + }; + } + const detail = normalizedErrorMessage(input.cause); + const lower = detail?.toLowerCase() ?? ""; + const category = isOpenCode2RuntimeError(input.cause) ? input.cause.category : null; + + if (input.isExternalServer) { + if (category === "external-server-password-required") { + return { + installed: true, + message: + "The configured OpenCode 2 server requires a password. OpenCode 2 has no unauthenticated mode.", + }; + } + if ( + category === "authentication-failed" || + lower.includes("401") || + lower.includes("403") || + lower.includes("unauthorized") || + lower.includes("forbidden") + ) { + return { + installed: true, + message: + "OpenCode 2 server rejected authentication. Check the server URL and password. OpenCode 2 has no unauthenticated mode.", + }; + } + if ( + category === "network-failed" || + lower.includes("econnrefused") || + lower.includes("enotfound") || + lower.includes("fetch failed") || + lower.includes("networkerror") || + lower.includes("timed out") || + lower.includes("timeout") || + lower.includes("socket hang up") + ) { + return { + installed: true, + message: `Couldn't reach the configured OpenCode 2 server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, + }; + } + return { + installed: true, + message: detail ?? "Failed to connect to the configured OpenCode 2 server.", + }; + } + + if (category === "binary-not-found" || lower.includes("enoent") || lower.includes("notfound")) { + return { + installed: false, + message: "OpenCode 2 CLI (`opencode2`) is not installed or not on PATH.", + }; + } + if (category === "placeholder-binary" || lower.includes("postinstall")) { + return { + installed: false, + message: + "The `@opencode-ai/cli` package shipped its placeholder binary: its postinstall script never ran. Reinstall with dependency build scripts enabled.", + }; + } + if (category === "quarantined-binary" || lower.includes("quarantine")) { + return { + installed: true, + message: + "macOS is blocking the OpenCode 2 binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode2)` to fix this.", + }; + } + return { + installed: true, + message: detail + ? `Failed to execute OpenCode 2 CLI health check: ${detail}` + : "Failed to execute OpenCode 2 CLI health check.", + }; +} + +function titleCaseSlug(value: string): string { + if (value === "opencode") return "OpenCode"; + if (value === "openai") return "OpenAI"; + if (value === "xai") return "xAI"; + const segments: Array = []; + for (const segment of value.split(/[-_/]+/)) { + if (segment.length > 0) segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); + } + return segments.join(" "); +} + +const DEFAULT_OPENCODE2_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +export interface OpenCode2Inventory { + readonly models: ReadonlyArray; + readonly agents: ReadonlyArray; +} + +export interface OpenCode2InventorySnapshot extends OpenCode2Inventory { + readonly connectedIntegrationIDs: ReadonlyArray; +} + +interface OpenCode2InventoryResult { + readonly inventory: OpenCode2InventorySnapshot; + readonly version: string | null; +} + +interface OpenCode2InventorySettlementOptions { + readonly maxAttempts?: number; + readonly minimumAttempts?: number; + readonly quietAttempts?: number; + readonly retryDelayMs?: number; +} + +function openCode2InventoryFingerprint(inventory: OpenCode2InventorySnapshot): string { + return JSON.stringify({ + agents: inventory.agents + .map((agent) => [agent.id, agent.name, agent.mode, agent.hidden] as const) + .toSorted(([left], [right]) => left.localeCompare(right)), + connectedIntegrationIDs: inventory.connectedIntegrationIDs.toSorted(), + models: inventory.models + .map( + (model) => + [ + model.providerID, + model.id, + model.name, + model.enabled, + model.variants.map((variant) => variant.id).toSorted(), + ] as const, + ) + .toSorted(([leftProvider, leftModel], [rightProvider, rightModel]) => + leftProvider === rightProvider + ? leftModel.localeCompare(rightModel) + : leftProvider.localeCompare(rightProvider), + ), + }); +} + +function openCode2InventoryIsUsable(inventory: OpenCode2InventorySnapshot): boolean { + const enabledModels = inventory.models.filter((model) => model.enabled); + if (enabledModels.length === 0) return false; + if (inventory.connectedIntegrationIDs.length === 0) return false; + const modelProviders = new Set(enabledModels.map((model) => model.providerID)); + return inventory.connectedIntegrationIDs.some((integrationID) => + modelProviders.has(integrationID), + ); +} + +export class OpenCode2InventorySettlementError extends Schema.TaggedErrorClass()( + "OpenCode2InventorySettlementError", + { attempts: Schema.Int }, +) { + override get message(): string { + return "OpenCode 2 inventory did not stabilize before the retry limit."; + } +} + +export const isOpenCode2InventorySettlementError = Schema.is(OpenCode2InventorySettlementError); + +/** + * A newly spawned 2.x server prints its ready banner before plugin settlement. + * The first non-empty model snapshot may therefore contain only baseline free + * models while authenticated integrations are still loading. Observe several + * snapshots, require a quiet interval after the minimum observation window, + * and keep waiting until at least one model-bearing connected integration is + * visible. The attempt cap is the only logged-out completion signal and also + * bounds broken-plugin and local-only cases. + * + * @internal exported for tests + */ +export const settleOpenCode2Inventory = Effect.fn("settleOpenCode2Inventory")(function* ( + readInventory: Effect.Effect, + options?: OpenCode2InventorySettlementOptions, +): Effect.fn.Return { + const maxAttempts = Math.max(1, options?.maxAttempts ?? 51); + const minimumAttempts = Math.min(maxAttempts, Math.max(1, options?.minimumAttempts ?? 6)); + const quietAttempts = Math.max(1, options?.quietAttempts ?? 2); + const retryDelayMs = Math.max(0, options?.retryDelayMs ?? 100); + let inventory = yield* readInventory; + let fingerprint = openCode2InventoryFingerprint(inventory); + let consecutiveMatches = 1; + let settledInventory: OpenCode2InventorySnapshot | null = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + if (attempt >= minimumAttempts && consecutiveMatches >= quietAttempts) { + settledInventory = inventory; + if (openCode2InventoryIsUsable(inventory)) return inventory; + } + if (attempt === maxAttempts) break; + yield* Effect.sleep(retryDelayMs); + inventory = yield* readInventory; + const nextFingerprint = openCode2InventoryFingerprint(inventory); + consecutiveMatches = nextFingerprint === fingerprint ? consecutiveMatches + 1 : 1; + fingerprint = nextFingerprint; + } + + if (settledInventory !== null) return settledInventory; + return yield* new OpenCode2InventorySettlementError({ attempts: maxAttempts }); +}); + +const OPENCODE2_VARIANT_LABELS: Record = { + xhigh: "Extra High", +}; + +function inferOpenCode2DefaultVariant( + providerID: string, + variants: ReadonlyArray, +): string | undefined { + return ( + inferOpenCodeDefaultVariant(providerID, variants) ?? + variants.find((variant) => variant === "medium") ?? + variants.find((variant) => variant === "high") ?? + variants.find((variant) => variant !== "none") ?? + variants[0] + ); +} + +/** + * Variants are opencode2's reasoning axis (effort ladders, thinking toggles, + * budget tiers), synthesized per model from models.dev capability flags. + */ +function openCode2CapabilitiesForModel(input: { + readonly model: ModelInfo; + readonly agents: ReadonlyArray; +}): ModelCapabilities { + const variantValues = input.model.variants + .map((variant) => variant.id) + .filter((variant) => variant !== OPENCODE2_DEFAULT_VARIANT); + const defaultVariant = inferOpenCode2DefaultVariant(input.model.providerID, variantValues); + const variantOptions = variantValues.map((variant) => { + const option = { + id: variant, + label: OPENCODE2_VARIANT_LABELS[variant] ?? titleCaseSlug(variant), + }; + return variant === defaultVariant ? { ...option, isDefault: true as const } : option; + }); + const primaryAgents = input.agents.filter( + (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), + ); + // The standalone Build/Plan interaction-mode toggle owns both native agents. + // Never expose either one through model options, including while startup has + // reported only half of the pair. Custom agents remain available behind an + // Auto sentinel that defers to the toggle. + const customAgents = primaryAgents.filter( + (agent) => agent.id !== "build" && agent.id !== "plan" && agent.id !== OPENCODE2_AUTO_AGENT, + ); + const hasNativeAgentPair = + primaryAgents.some((agent) => agent.id === "build") && + primaryAgents.some((agent) => agent.id === "plan"); + const agentOptions = + !hasNativeAgentPair || customAgents.length === 0 + ? [] + : [ + { id: OPENCODE2_AUTO_AGENT, label: "Auto (Build/Plan)" }, + ...customAgents.map((agent) => ({ id: agent.id, label: titleCaseSlug(agent.name) })), + ]; + const defaultVariantSelection = defaultVariant ? { currentValue: defaultVariant } : {}; + return createModelCapabilities({ + optionDescriptors: [ + ...(variantOptions.length > 0 + ? [ + { + id: "variant", + label: "Reasoning", + type: "select" as const, + options: variantOptions, + ...defaultVariantSelection, + }, + ] + : []), + ...(agentOptions.length > 0 + ? [ + { + id: "agent", + label: "Agent", + type: "select" as const, + options: agentOptions, + currentValue: OPENCODE2_AUTO_AGENT, + }, + ] + : []), + ], + }); +} + +export function flattenOpenCode2Models( + inventory: OpenCode2Inventory, +): ReadonlyArray { + const models: Array = []; + for (const model of inventory.models) { + if (!model.enabled) continue; + const name = nonEmptyTrimmed(model.name); + if (!name) continue; + models.push({ + slug: `${model.providerID}/${model.id}`, + name, + subProvider: titleCaseSlug(model.providerID), + isCustom: false, + capabilities: openCode2CapabilitiesForModel({ model, agents: inventory.agents }), + }); + } + return models.toSorted((left, right) => left.name.localeCompare(right.name)); +} + +/** + * Reads the 2.x inventory and version over HTTP, spawning a server when none + * is configured. `/api/model` and `/api/agent` replaced the inventory CLI + * subcommands, while `/api/health` avoids a second binary launch for version. + */ +const loadOpenCode2Inventory = (input: { + readonly runtime: OpenCode2Runtime["Service"]; + readonly settings: OpenCode2Settings; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; +}): Effect.Effect< + OpenCode2InventoryResult, + OpenCode2InventorySettlementError | OpenCode2RuntimeError +> => + Effect.scoped( + Effect.gen(function* () { + const server = yield* input.runtime.connectToOpenCode2Server({ + binaryPath: input.settings.binaryPath, + serverUrl: input.settings.serverUrl, + serverPassword: input.settings.serverPassword, + environment: input.environment, + }); + const client = input.runtime.createOpenCode2SdkClient({ + baseUrl: server.url, + directory: input.cwd, + serverPassword: server.password, + }); + const location = { directory: input.cwd }; + const [inventory, healthResponse] = yield* Effect.all( + [ + settleOpenCode2Inventory( + Effect.gen(function* () { + const [modelResponse, agentResponse, integrationResponse] = yield* Effect.all( + [ + runOpenCode2Sdk("model.list", () => client.v2.model.list({ location })), + runOpenCode2Sdk("agent.list", () => client.v2.agent.list({ location })), + runOpenCode2Sdk("integration.list", () => + client.v2.integration.list({ location }), + ), + ], + { concurrency: "unbounded" }, + ); + return { + models: modelResponse.data?.data ?? [], + agents: agentResponse.data?.data ?? [], + connectedIntegrationIDs: (integrationResponse.data?.data ?? []) + .filter((integration: IntegrationInfo) => integration.connections.length > 0) + .map((integration: IntegrationInfo) => integration.id), + } satisfies OpenCode2InventorySnapshot; + }), + ), + runOpenCode2Sdk("health.get", () => client.v2.health.get()), + ], + { concurrency: "unbounded" }, + ); + return { + inventory, + version: parseOpenCode2Version(healthResponse.data?.version ?? ""), + }; + }), + ); + +export const makePendingOpenCode2Provider = ( + settings: OpenCode2Settings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = providerModelsFromSettings( + [], + settings.customModels, + DEFAULT_OPENCODE2_MODEL_CAPABILITIES, + ); + return buildServerProvider({ + presentation: OPENCODE2_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: settings.enabled + ? "OpenCode 2 provider status has not been checked in this session yet." + : "OpenCode 2 is disabled in T3 Code settings.", + }, + }); + }); + +export const checkOpenCode2ProviderStatus = Effect.fn("checkOpenCode2ProviderStatus")(function* ( + settings: OpenCode2Settings, + cwd: string, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return { + const runtime = yield* OpenCode2Runtime; + const resolvedEnvironment = environment ?? process.env; + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const customModels = settings.customModels; + const isExternalServer = settings.serverUrl.trim().length > 0; + + const draft = (input: { + readonly installed: boolean; + readonly version: string | null; + readonly status: "ready" | "warning" | "error"; + readonly message: string; + readonly models?: ReadonlyArray; + readonly authenticated?: boolean; + }) => + buildServerProvider({ + presentation: OPENCODE2_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: providerModelsFromSettings( + input.models ?? [], + customModels, + DEFAULT_OPENCODE2_MODEL_CAPABILITIES, + ), + probe: { + installed: input.installed, + version: input.version, + status: input.status, + auth: input.authenticated + ? { status: "authenticated", type: "opencode" } + : { status: "unknown" }, + message: input.message, + }, + }); + + const fallback = (cause: unknown, version: string | null = null) => { + const failure = formatOpenCode2ProbeError({ + cause, + isExternalServer, + serverUrl: settings.serverUrl, + }); + return draft({ + installed: failure.installed, + version, + status: "error", + message: failure.message, + }); + }; + + if (!settings.enabled) { + return draft({ + installed: false, + version: null, + status: "warning", + message: isExternalServer + ? "OpenCode 2 is disabled in T3 Code settings. A server URL is configured." + : "OpenCode 2 is disabled in T3 Code settings.", + }); + } + + const inventoryExit = yield* Effect.exit( + loadOpenCode2Inventory({ + runtime, + settings, + cwd, + environment: resolvedEnvironment, + }), + ); + + if (inventoryExit._tag === "Failure") { + return fallback(Cause.squash(inventoryExit.cause)); + } + + const version = inventoryExit.value.version; + if (version === null) { + return fallback(new Error("Unable to determine OpenCode 2 version from `/api/health`.")); + } + const build = openCode2NextBuild(version); + if (build !== null && build < MINIMUM_OPENCODE2_NEXT_BUILD) { + return draft({ + installed: true, + version, + status: "error", + message: isExternalServer + ? `The configured OpenCode 2 server reports ${version}, which is older than the verified build next-${MINIMUM_OPENCODE2_NEXT_BUILD}. Upgrade that server to next-${MINIMUM_OPENCODE2_NEXT_BUILD} or newer.` + : `OpenCode 2 ${version} is older than the verified build next-${MINIMUM_OPENCODE2_NEXT_BUILD}. Upgrade with \`npm install -g @opencode-ai/cli@next\`.`, + }); + } + + const models = flattenOpenCode2Models(inventoryExit.value.inventory); + const connectedIntegrationCount = inventoryExit.value.inventory.connectedIntegrationIDs.length; + let message = "Connected to OpenCode 2, but it did not report any enabled models."; + if (models.length > 0) { + const modelLabel = models.length === 1 ? "model" : "models"; + const sourceLabel = isExternalServer ? "the configured OpenCode 2 server" : "OpenCode 2"; + let integrationLabel = "without a connected authenticated integration"; + if (connectedIntegrationCount > 0) { + const integrationNoun = connectedIntegrationCount === 1 ? "integration" : "integrations"; + integrationLabel = `with ${connectedIntegrationCount} connected ${integrationNoun}`; + } + message = `${models.length} ${modelLabel} available through ${sourceLabel} ${integrationLabel}.`; + } + return draft({ + installed: true, + version, + status: models.length > 0 ? "ready" : "warning", + authenticated: connectedIntegrationCount > 0, + models, + message, + }); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 21014e33f08..1b5a72ec72f 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -12,6 +12,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { buildServerProvider, + inferOpenCodeDefaultVariant, nonEmptyTrimmed, parseGenericCliVersion, providerModelsFromSettings, @@ -144,22 +145,6 @@ function titleCaseSlug(value: string): string { return segments.join(" "); } -function inferDefaultVariant( - providerID: string, - variants: ReadonlyArray, -): string | undefined { - if (variants.length === 1) { - return variants[0]; - } - if (providerID === "anthropic" || providerID.startsWith("google")) { - return variants.includes("high") ? "high" : undefined; - } - if (providerID === "openai" || providerID === "opencode") { - return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; - } - return undefined; -} - function inferDefaultAgent(agents: ReadonlyArray): string | undefined { return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; } @@ -174,7 +159,7 @@ function openCodeCapabilitiesForModel(input: { readonly agents: ReadonlyArray; }): ModelCapabilities { const variantValues = Object.keys(input.model.variants ?? {}); - const defaultVariant = inferDefaultVariant(input.providerID, variantValues); + const defaultVariant = inferOpenCodeDefaultVariant(input.providerID, variantValues); const variantOptions = variantValues.map((value) => defaultVariant === value ? { id: value, label: titleCaseSlug(value), isDefault: true as const } diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 65422bde7da..d6989ddb45a 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -49,6 +49,8 @@ import { CodexDriver, type CodexDriverEnv } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import * as OpenCode2Runtime from "../opencode2Runtime.ts"; +import * as SpawnedProcessReaper from "../SpawnedProcessReaper.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -281,7 +283,11 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs // surfaced; that merged layer then provides `ServerConfig.layerTest`'s // `FileSystem` dep while keeping everything else surfaced to the test. - const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); + const infraLayer = OpenCodeRuntimeLive.pipe( + Layer.provideMerge(OpenCode2Runtime.layer), + Layer.provideMerge(SpawnedProcessReaper.layer), + Layer.provideMerge(NodeServices.layer), + ); const baseLayer = ServerConfig.layerTest(process.cwd(), { prefix: "provider-instance-registry-all-drivers-test", }).pipe( diff --git a/apps/server/src/provider/Layers/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/Layers/ProviderOrchestrationAdapterInfrastructure.ts index 747654fe39f..fbc0020add4 100644 --- a/apps/server/src/provider/Layers/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/Layers/ProviderOrchestrationAdapterInfrastructure.ts @@ -14,6 +14,7 @@ import { } from "../../orchestration-v2/Adapters/CursorAgentSdk.ts"; import { IdAllocatorV2, layer as idAllocatorLayer } from "../../orchestration-v2/IdAllocator.ts"; import { layer as providerContinuationRequestsLayer } from "../../orchestration-v2/ProviderContinuationRequests.ts"; +import { layer as providerInteractionModeReflectionsLayer } from "../../orchestration-v2/ProviderInteractionModeReflections.ts"; export type ProviderOrchestrationAdapterInfrastructure = | ClaudeAgentSdkQueryRunner @@ -23,9 +24,11 @@ export type ProviderOrchestrationAdapterInfrastructure = /** * Infrastructure shared by the V2 adapters materialized inside provider - * instances. `providerContinuationRequestsLayer` must be the same layer - * reference the orchestration runtime provides to its continuation worker so - * Effect layer memoization yields one shared queue. + * instances. `providerContinuationRequestsLayer` and + * `providerInteractionModeReflectionsLayer` must be the same layer references + * the orchestration runtime provides to its worker halves so Effect layer + * memoization yields one shared queue each; a missing entry here silently + * resolves the Context.Reference default, which drops every offer. */ export const ProviderOrchestrationAdapterInfrastructureLive = Layer.mergeAll( claudeAgentSdkQueryRunnerLiveLayer, @@ -33,4 +36,5 @@ export const ProviderOrchestrationAdapterInfrastructureLive = Layer.mergeAll( cursorAgentSdkRunnerLiveLayer, idAllocatorLayer, providerContinuationRequestsLayer, + providerInteractionModeReflectionsLayer, ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 32f6448dab5..38a05c30df2 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -34,6 +34,8 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import * as OpenCode2Runtime from "../opencode2Runtime.ts"; +import * as SpawnedProcessReaper from "../SpawnedProcessReaper.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -684,6 +686,135 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("retains stale OpenCode 2 models while the initial probe is pending", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode2"), + driver: ProviderDriverKind.make("opencode2"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-28T00:00:00.000Z", + version: "0.0.0-next-16383", + models: [ + { + slug: "opencode/big-pickle", + name: "Big Pickle", + subProvider: "OpenCode", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const pendingProvider = { + ...previousProvider, + status: "warning", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-28T00:01:00.000Z", + version: null, + models: [], + message: "OpenCode 2 provider status has not been checked in this session yet.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, pendingProvider).models, [ + ...previousProvider.models, + ]); + }); + + it("clears stale OpenCode 2 capabilities after a successful refresh", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode2"), + driver: ProviderDriverKind.make("opencode2"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "unknown" }, + checkedAt: "2026-07-28T00:00:00.000Z", + version: "0.0.0-next-16383", + models: [ + { + slug: "opencode/big-pickle", + name: "Big Pickle", + subProvider: "OpenCode", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("agent", "Agent", [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ]), + ], + }), + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-07-28T00:01:00.000Z", + models: [ + { + ...previousProvider.models[0], + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider).models, + refreshedProvider.models, + ); + }); + + it("keeps OpenCode 1 capability carry-forward unchanged", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-28T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "opencode/big-pickle", + name: "Big Pickle", + subProvider: "OpenCode", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("variant", "Variant", [ + { id: "high", label: "High", isDefault: true }, + ]), + ], + }), + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-07-28T00:01:00.000Z", + models: [ + { + ...previousProvider.models[0], + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider).models, + previousProvider.models, + ); + }); + it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), @@ -1486,6 +1617,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCode2Runtime.layer), + Layer.provideMerge(SpawnedProcessReaper.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the // outer `NodeServices.layer` on `it.layer(...)` and will @@ -1579,6 +1712,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCode2Runtime.layer), + Layer.provideMerge(SpawnedProcessReaper.layer), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { spawnedCommands.push((command as { readonly command: string }).command); @@ -1701,6 +1836,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCode2Runtime.layer), + Layer.provideMerge(SpawnedProcessReaper.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), ); @@ -1763,6 +1900,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCode2Runtime.layer), + Layer.provideMerge(SpawnedProcessReaper.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 760c8e1c59e..7a0ef3c48b8 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -78,14 +78,19 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; +const OPEN_CODE_DRIVER_KINDS = new Set([ + ProviderDriverKind.make("opencode"), + ProviderDriverKind.make("opencode2"), +]); + const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { - if (provider.driver !== ProviderDriverKind.make("opencode")) { + if (!OPEN_CODE_DRIVER_KINDS.has(provider.driver)) { return true; } - // OpenCode's initial snapshot is deliberately non-authoritative while its - // first probe is still running. A probe error from an installed CLI/server - // is likewise partial: it could not establish the current inventory. + // OpenCode snapshots are deliberately non-authoritative while their first + // probe is still running. A probe error from an installed CLI/server is + // likewise partial: it could not establish the current inventory. // Conversely, disabled and missing-CLI snapshots are authoritative removals, // as are successful ready/warning inventories (including an empty one after // logout or plugin removal). @@ -107,9 +112,16 @@ const mergeProviderModels = ( } const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); + const shouldClearSuccessfulOpenCode2Capabilities = + provider.driver === ProviderDriverKind.make("opencode2") && !shouldRetainMissingModels; const mergedModels = nextModels.map((model) => { const previousModel = previousBySlug.get(model.slug); - if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) { + if ( + !previousModel || + shouldClearSuccessfulOpenCode2Capabilities || + hasModelCapabilities(model) || + !hasModelCapabilities(previousModel) + ) { return model; } return { diff --git a/apps/server/src/provider/OpenCode2ProviderEnvironment.test.ts b/apps/server/src/provider/OpenCode2ProviderEnvironment.test.ts new file mode 100644 index 00000000000..50f27e8aed6 --- /dev/null +++ b/apps/server/src/provider/OpenCode2ProviderEnvironment.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + applyOpenCode2ProviderEnvironment, + OPENCODE2_BACKGROUND_SUBAGENTS_ENV, +} from "./OpenCode2ProviderEnvironment.ts"; + +describe("applyOpenCode2ProviderEnvironment", () => { + it("explicitly enables background subagents for a managed server", () => { + expect( + applyOpenCode2ProviderEnvironment( + { backgroundSubagents: true, serverUrl: "" }, + { + OPENCODE_EXPERIMENTAL: "false", + [OPENCODE2_BACKGROUND_SUBAGENTS_ENV]: "false", + }, + ), + ).toMatchObject({ + OPENCODE_EXPERIMENTAL: "false", + [OPENCODE2_BACKGROUND_SUBAGENTS_ENV]: "true", + }); + }); + + it("explicitly disables background subagents even under the umbrella experiment", () => { + expect( + applyOpenCode2ProviderEnvironment( + { backgroundSubagents: false, serverUrl: "" }, + { OPENCODE_EXPERIMENTAL: "true" }, + ), + ).toMatchObject({ + OPENCODE_EXPERIMENTAL: "true", + [OPENCODE2_BACKGROUND_SUBAGENTS_ENV]: "false", + }); + }); + + it("does not claim control over an external server environment", () => { + const environment = { [OPENCODE2_BACKGROUND_SUBAGENTS_ENV]: "external" }; + + expect( + applyOpenCode2ProviderEnvironment( + { backgroundSubagents: true, serverUrl: " http://127.0.0.1:4096 " }, + environment, + ), + ).toBe(environment); + }); +}); diff --git a/apps/server/src/provider/OpenCode2ProviderEnvironment.ts b/apps/server/src/provider/OpenCode2ProviderEnvironment.ts new file mode 100644 index 00000000000..75b3fba0468 --- /dev/null +++ b/apps/server/src/provider/OpenCode2ProviderEnvironment.ts @@ -0,0 +1,17 @@ +import type { OpenCode2Settings } from "@t3tools/contracts"; + +export const OPENCODE2_BACKGROUND_SUBAGENTS_ENV = "OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"; + +export function applyOpenCode2ProviderEnvironment( + settings: Pick, + environment: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + if (settings.serverUrl.trim().length > 0) { + return environment; + } + + return { + ...environment, + [OPENCODE2_BACKGROUND_SUBAGENTS_ENV]: settings.backgroundSubagents ? "true" : "false", + }; +} diff --git a/apps/server/src/provider/SpawnedProcessReaper.test.ts b/apps/server/src/provider/SpawnedProcessReaper.test.ts new file mode 100644 index 00000000000..3cc5fe944c6 --- /dev/null +++ b/apps/server/src/provider/SpawnedProcessReaper.test.ts @@ -0,0 +1,580 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeTimers from "node:timers"; + +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +import { + makeSpawnedProcessReaper, + SIDECAR_SOURCE, + type SpawnedProcessReaperEntry, + SpawnedProcessReaperError, + type SpawnedProcessReaperSidecar, + type SpawnedProcessReaperSpawner, + windowsTaskkillArgs, +} from "./SpawnedProcessReaper.ts"; + +const dummySource = "setInterval(() => {}, 1000);"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- These tests must skip POSIX-only process-group assertions on Windows. +const isWindows = process.platform === "win32"; + +function spawnDummy(): NodeChildProcess.ChildProcess { + return NodeChildProcess.spawn(process.execPath, ["-e", dummySource], { + detached: true, + stdio: "ignore", + }); +} + +function spawnSidecar(): NodeChildProcess.ChildProcess { + return NodeChildProcess.spawn(process.execPath, ["-e", SIDECAR_SOURCE], { + detached: true, + windowsHide: isWindows, + stdio: ["pipe", "ignore", "ignore"], + }); +} + +const shortLivedParentSource = ` +"use strict"; +const { spawn } = require("node:child_process"); +const sidecar = spawn(process.execPath, ["-e", process.env.SIDECAR_SOURCE], { + detached: true, + stdio: ["pipe", "ignore", "ignore"], +}); +sidecar.unref(); +process.stdout.write(String(sidecar.pid) + "\\n"); +sidecar.stdin.end(process.env.TRACK_MESSAGE, () => setTimeout(() => process.exit(0), 250)); +`; + +function spawnSidecarFromShortLivedParent(track: string): NodeChildProcess.ChildProcess { + return NodeChildProcess.spawn(process.execPath, ["-e", shortLivedParentSource], { + env: { ...process.env, SIDECAR_SOURCE, TRACK_MESSAGE: track }, + stdio: ["ignore", "pipe", "ignore"], + }); +} + +function readSpawnedPid(parent: NodeChildProcess.ChildProcess): Promise { + return new Promise((resolve, reject) => { + const stdout = parent.stdout; + if (stdout === null) { + reject(new Error("Short-lived sidecar parent did not expose stdout")); + return; + } + let output = ""; + stdout.on("data", (chunk: Buffer | string) => { + output += String(chunk); + const pid = Number(output.trim()); + if (Number.isFinite(pid)) resolve(pid); + }); + parent.once("error", reject); + }); +} + +function waitForMs(timeoutMs: number): Promise { + return new Promise((resolve) => { + // @effect-diagnostics-next-line globalTimers:off + NodeTimers.setTimeout(resolve, timeoutMs); + }); +} + +function waitForExit(child: NodeChildProcess.ChildProcess, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return new Promise((resolve, reject) => { + // @effect-diagnostics-next-line globalTimers:off + const timeout = NodeTimers.setTimeout(() => { + child.removeListener("exit", onExit); + child.removeListener("error", onError); + reject(new Error(`Process ${String(child.pid)} did not exit within ${timeoutMs}ms`)); + }, timeoutMs); + const onExit = () => { + NodeTimers.clearTimeout(timeout); + resolve(); + }; + const onError = (cause: Error) => { + NodeTimers.clearTimeout(timeout); + reject(cause); + }; + child.once("exit", onExit); + child.once("error", onError); + }); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function killForCleanup(child: NodeChildProcess.ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null || child.killed) return; + child.kill("SIGKILL"); + await waitForExit(child, 5_000).catch(() => undefined); +} + +async function killPidForCleanup(pid: number | null): Promise { + if (pid === null || !isProcessAlive(pid)) return; + try { + process.kill(pid, "SIGKILL"); + } catch {} + for (let attempt = 0; attempt < 100 && isProcessAlive(pid); attempt += 1) { + await waitForMs(50); + } +} + +function trackMessage(entry: SpawnedProcessReaperEntry): string { + return `${JSON.stringify({ op: "track", ...entry })}\n`; +} + +function untrackMessage(pid: number): string { + return `${JSON.stringify({ op: "untrack", pid })}\n`; +} + +interface FakeSpawnerOptions { + readonly exitImmediately?: boolean; + readonly failSpawns?: ReadonlySet; + readonly failWriteMessageIndicesForSpawn?: ReadonlyMap>; + readonly failWritesForSpawn?: ReadonlySet; +} + +function makeFakeSpawner(options: FakeSpawnerOptions = {}) { + const spawnSignals = Array.from({ length: 16 }, () => Deferred.makeUnsafe()); + let spawnAttempts = 0; + const sidecars: Array<{ + readonly exit: Deferred.Deferred; + readonly messages: Array; + readonly messageSignals: Array>; + ended: boolean; + terminated: boolean; + readonly handle: SpawnedProcessReaperSidecar; + }> = []; + const spawner: SpawnedProcessReaperSpawner = { + spawn: () => + Effect.suspend(() => { + const spawnIndex = spawnAttempts; + spawnAttempts += 1; + if (options.failSpawns?.has(spawnIndex)) { + Deferred.doneUnsafe(spawnSignals[spawnIndex]!, Effect.void); + return Effect.fail( + new SpawnedProcessReaperError({ + operation: "spawn", + cause: `sidecar spawn ${spawnIndex} failed`, + }), + ); + } + return Effect.sync(() => { + const sidecarIndex = sidecars.length; + const exit = Deferred.makeUnsafe(); + const state = { + exit, + messages: [] as Array, + messageSignals: Array.from({ length: 8 }, () => Deferred.makeUnsafe()), + ended: false, + terminated: false, + handle: undefined as unknown as SpawnedProcessReaperSidecar, + }; + state.handle = { + stdin: { + write: (chunk) => { + const messageIndex = state.messages.length; + if ( + options.failWritesForSpawn?.has(sidecarIndex) || + options.failWriteMessageIndicesForSpawn?.get(sidecarIndex)?.has(messageIndex) === + true + ) { + throw new Error(`sidecar ${sidecarIndex} write failed`); + } + state.messages.push(chunk); + Deferred.doneUnsafe(state.messageSignals[messageIndex]!, Effect.void); + return true; + }, + end: () => { + state.ended = true; + }, + }, + exitCode: Deferred.await(exit), + terminate: Effect.sync(() => { + state.terminated = true; + Deferred.doneUnsafe(exit, Effect.succeed(null)); + }), + }; + sidecars.push(state); + Deferred.doneUnsafe(spawnSignals[spawnIndex]!, Effect.void); + if (options.exitImmediately) { + Deferred.doneUnsafe(exit, Effect.succeed(1)); + } + return state.handle; + }); + }), + }; + return { + get spawnAttempts() { + return spawnAttempts; + }, + sidecars, + spawnSignals, + spawner, + }; +} + +const posixEntry: SpawnedProcessReaperEntry = { + pid: 100, + pgid: 100, + platform: "posix", +}; + +describe("SpawnedProcessReaper sidecar", () => { + it("treats parent reparenting as death before PID liveness", () => { + assert.isTrue(SIDECAR_SOURCE.includes("if (process.ppid !== parentPid) return true;")); + }); + + it("exits nonzero when its stdin errors", () => { + assert.isTrue(SIDECAR_SOURCE.includes('process.stdin.on("error", () => process.exit(1));')); + }); + + it.skipIf(isWindows)( + "reaps tracked process trees after the parent dies at stdin EOF", + async () => { + const dummy = spawnDummy(); + const parent = spawnSidecarFromShortLivedParent( + trackMessage({ pid: dummy.pid!, pgid: dummy.pid!, platform: "posix" }).trimEnd(), + ); + let sidecarPid: number | null = null; + try { + sidecarPid = await readSpawnedPid(parent); + await waitForExit(parent, 5_000); + await waitForExit(dummy, 5_000); + assert.isFalse(isProcessAlive(dummy.pid!)); + } finally { + await killForCleanup(parent); + await killPidForCleanup(sidecarPid); + await killForCleanup(dummy); + } + }, + ); + + it.skipIf(isWindows)("does not reap tracked processes while the parent is alive", async () => { + const dummy = spawnDummy(); + const sidecar = spawnSidecar(); + try { + sidecar.stdin!.write(trackMessage({ pid: dummy.pid!, pgid: dummy.pid!, platform: "posix" })); + sidecar.stdin!.end(); + await waitForMs(2_500); + assert.isTrue(isProcessAlive(dummy.pid!)); + } finally { + await killForCleanup(sidecar); + await killForCleanup(dummy); + } + }); + + it.skipIf(isWindows)("does not reap an explicitly untracked process", async () => { + const dummy = spawnDummy(); + const sidecar = spawnSidecar(); + try { + sidecar.stdin!.write(trackMessage({ pid: dummy.pid!, pgid: dummy.pid!, platform: "posix" })); + sidecar.stdin!.write(untrackMessage(dummy.pid!)); + sidecar.stdin!.end(); + await waitForMs(2_500); + assert.isTrue(isProcessAlive(dummy.pid!)); + } finally { + await killForCleanup(sidecar); + await killForCleanup(dummy); + } + }); + + it.skipIf(isWindows)("does not reap tracked processes after shutdown", async () => { + const dummy = spawnDummy(); + const sidecar = spawnSidecar(); + try { + sidecar.stdin!.write(trackMessage({ pid: dummy.pid!, pgid: dummy.pid!, platform: "posix" })); + sidecar.stdin!.write('{"op":"shutdown"}\n'); + sidecar.stdin!.end(); + await waitForExit(sidecar, 2_000); + assert.isTrue(isProcessAlive(dummy.pid!)); + } finally { + await killForCleanup(sidecar); + await killForCleanup(dummy); + } + }); +}); + +describe("SpawnedProcessReaper service", () => { + it.effect("spawns lazily, re-feeds its mirror after sidecar death, and untracks", () => + Effect.gen(function* () { + const fake = makeFakeSpawner(); + yield* Effect.scoped( + Effect.gen(function* () { + const reaper = yield* makeSpawnedProcessReaper(fake.spawner); + yield* reaper.track(posixEntry); + assert.lengthOf(fake.sidecars, 1); + assert.deepStrictEqual(fake.sidecars[0]!.messages, [trackMessage(posixEntry)]); + + const secondEntry = { ...posixEntry, pid: 101, pgid: 101 }; + yield* reaper.track(secondEntry); + yield* Deferred.succeed(fake.sidecars[0]!.exit, 1); + yield* Deferred.await(fake.spawnSignals[1]!); + yield* Deferred.await(fake.sidecars[1]!.messageSignals[1]!); + + assert.lengthOf(fake.sidecars, 2); + assert.deepStrictEqual(fake.sidecars[1]!.messages, [ + trackMessage(posixEntry), + trackMessage(secondEntry), + ]); + yield* reaper.untrack(posixEntry.pid); + assert.deepStrictEqual(fake.sidecars[1]!.messages.at(-1), untrackMessage(posixEntry.pid)); + }), + ); + }), + ); + + it.effect("gives up after three rapid sidecar deaths", () => + Effect.gen(function* () { + const fake = makeFakeSpawner({ exitImmediately: true }); + yield* Effect.scoped( + Effect.gen(function* () { + const reaper = yield* makeSpawnedProcessReaper(fake.spawner); + yield* reaper.track(posixEntry); + yield* Deferred.await(fake.spawnSignals[2]!); + yield* TestClock.adjust("1 millis"); + assert.lengthOf(fake.sidecars, 3); + }), + ); + }), + ); + + it.effect("resets the respawn budget after a sidecar outlives the window", () => + Effect.gen(function* () { + const fake = makeFakeSpawner(); + yield* Effect.scoped( + Effect.gen(function* () { + const reaper = yield* makeSpawnedProcessReaper(fake.spawner); + yield* reaper.track(posixEntry); + + yield* Deferred.succeed(fake.sidecars[0]!.exit, 1); + yield* Deferred.await(fake.spawnSignals[1]!); + yield* Deferred.await(fake.sidecars[1]!.messageSignals[0]!); + + yield* Deferred.succeed(fake.sidecars[1]!.exit, 1); + yield* Deferred.await(fake.spawnSignals[2]!); + yield* Deferred.await(fake.sidecars[2]!.messageSignals[0]!); + + yield* TestClock.adjust("60 seconds"); + yield* TestClock.adjust("1 millis"); + yield* Deferred.succeed(fake.sidecars[2]!.exit, 1); + yield* Deferred.await(fake.spawnSignals[3]!); + yield* Deferred.await(fake.sidecars[3]!.messageSignals[0]!); + + assert.lengthOf(fake.sidecars, 4); + }), + ); + }), + ); + + it.effect("replaces a sidecar after a write failure and re-feeds the mirror", () => + Effect.gen(function* () { + const fake = makeFakeSpawner({ failWritesForSpawn: new Set([0]) }); + yield* Effect.scoped( + Effect.gen(function* () { + const reaper = yield* makeSpawnedProcessReaper(fake.spawner); + yield* reaper.track(posixEntry); + assert.lengthOf(fake.sidecars, 2); + assert.isTrue(fake.sidecars[0]!.terminated); + assert.deepStrictEqual(fake.sidecars[1]!.messages, [trackMessage(posixEntry)]); + }), + ); + }), + ); + + it.effect("terminates a sidecar that retains a failed untrack", () => + Effect.gen(function* () { + const fake = makeFakeSpawner({ + failWriteMessageIndicesForSpawn: new Map([[0, new Set([1])]]), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const reaper = yield* makeSpawnedProcessReaper(fake.spawner); + yield* reaper.track(posixEntry); + yield* reaper.untrack(posixEntry.pid); + + assert.lengthOf(fake.sidecars, 1); + assert.isTrue(fake.sidecars[0]!.terminated); + assert.deepStrictEqual(fake.sidecars[0]!.messages, [trackMessage(posixEntry)]); + }), + ); + }), + ); + + it.effect("retries a failed initial spawn while tracked work remains", () => + Effect.gen(function* () { + const fake = makeFakeSpawner({ failSpawns: new Set([0]) }); + yield* Effect.scoped( + Effect.gen(function* () { + const reaper = yield* makeSpawnedProcessReaper(fake.spawner); + yield* reaper.track(posixEntry); + assert.lengthOf(fake.sidecars, 0); + + yield* Effect.yieldNow; + yield* TestClock.adjust("100 millis"); + yield* Deferred.await(fake.spawnSignals[1]!); + yield* Deferred.await(fake.sidecars[0]!.messageSignals[0]!); + + assert.lengthOf(fake.sidecars, 1); + assert.deepStrictEqual(fake.sidecars[0]!.messages, [trackMessage(posixEntry)]); + }), + ); + }), + ); + + it.effect("keeps spawn retries separate from the sidecar crash budget", () => + Effect.gen(function* () { + const fake = makeFakeSpawner({ failSpawns: new Set([0, 1]) }); + yield* Effect.scoped( + Effect.gen(function* () { + const reaper = yield* makeSpawnedProcessReaper(fake.spawner); + yield* reaper.track(posixEntry); + + yield* TestClock.adjust("100 millis"); + yield* TestClock.adjust("100 millis"); + yield* Deferred.await(fake.spawnSignals[2]!); + yield* Deferred.await(fake.sidecars[0]!.messageSignals[0]!); + + yield* Deferred.succeed(fake.sidecars[0]!.exit, 1); + yield* Deferred.await(fake.spawnSignals[3]!); + yield* Deferred.await(fake.sidecars[1]!.messageSignals[0]!); + + assert.lengthOf(fake.sidecars, 2); + assert.deepStrictEqual(fake.sidecars[1]!.messages, [trackMessage(posixEntry)]); + }), + ); + }), + ); + + it.effect("allows a fresh workload after exhausting initial spawn retries", () => + Effect.gen(function* () { + const fake = makeFakeSpawner({ failSpawns: new Set([0, 1, 2]) }); + const freshEntry = { ...posixEntry, pid: 101, pgid: 101 }; + yield* Effect.scoped( + Effect.gen(function* () { + const reaper = yield* makeSpawnedProcessReaper(fake.spawner); + yield* reaper.track(posixEntry); + + yield* TestClock.adjust("100 millis"); + yield* TestClock.adjust("100 millis"); + yield* Deferred.await(fake.spawnSignals[2]!); + yield* reaper.untrack(posixEntry.pid); + yield* reaper.track(freshEntry); + yield* Deferred.await(fake.spawnSignals[3]!); + yield* Deferred.await(fake.sidecars[0]!.messageSignals[0]!); + + assert.deepStrictEqual(fake.sidecars[0]!.messages, [trackMessage(freshEntry)]); + }), + ); + }), + ); + + it.effect("retries the tracked workload after the initial spawn cooldown", () => + Effect.gen(function* () { + const fake = makeFakeSpawner({ failSpawns: new Set([0, 1, 2]) }); + yield* Effect.scoped( + Effect.gen(function* () { + const reaper = yield* makeSpawnedProcessReaper(fake.spawner); + yield* reaper.track(posixEntry); + + yield* TestClock.adjust("100 millis"); + yield* TestClock.adjust("100 millis"); + yield* Deferred.await(fake.spawnSignals[2]!); + yield* Effect.yieldNow; + yield* TestClock.adjust("59 seconds"); + assert.strictEqual(fake.spawnAttempts, 3); + assert.lengthOf(fake.sidecars, 0); + + yield* TestClock.adjust("1 second"); + yield* Deferred.await(fake.spawnSignals[3]!); + yield* Deferred.await(fake.sidecars[0]!.messageSignals[0]!); + + assert.deepStrictEqual(fake.sidecars[0]!.messages, [trackMessage(posixEntry)]); + }), + ); + }), + ); + + it.effect("sends shutdown and closes stdin during finalization", () => + Effect.gen(function* () { + const fake = makeFakeSpawner(); + yield* Effect.scoped( + Effect.gen(function* () { + const reaper = yield* makeSpawnedProcessReaper(fake.spawner); + yield* reaper.track(posixEntry); + }), + ); + assert.deepStrictEqual(fake.sidecars[0]!.messages, [ + trackMessage(posixEntry), + '{"op":"shutdown"}\n', + ]); + assert.isTrue(fake.sidecars[0]!.ended); + }), + ); + + it.effect("terminates a sidecar when its shutdown write fails", () => + Effect.gen(function* () { + const fake = makeFakeSpawner({ + failWriteMessageIndicesForSpawn: new Map([[0, new Set([1])]]), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const reaper = yield* makeSpawnedProcessReaper(fake.spawner); + yield* reaper.track(posixEntry); + }), + ); + + assert.deepStrictEqual(fake.sidecars[0]!.messages, [trackMessage(posixEntry)]); + assert.isTrue(fake.sidecars[0]!.terminated); + assert.isFalse(fake.sidecars[0]!.ended); + }), + ); + + it.effect("re-feeds tracked entries after an unrecoverable sidecar stdin error", () => + Effect.gen(function* () { + const fake = makeFakeSpawner(); + yield* Effect.scoped( + Effect.gen(function* () { + const reaper = yield* makeSpawnedProcessReaper(fake.spawner); + yield* reaper.track(posixEntry); + yield* Deferred.succeed(fake.sidecars[0]!.exit, 1); + yield* Deferred.await(fake.spawnSignals[1]!); + yield* Deferred.await(fake.sidecars[1]!.messageSignals[0]!); + + assert.deepStrictEqual(fake.sidecars[1]!.messages, [trackMessage(posixEntry)]); + }), + ); + }), + ); + + it.effect("does not propagate sidecar spawn failures", () => + Effect.gen(function* () { + const reaper = yield* makeSpawnedProcessReaper({ + spawn: () => + Effect.fail( + new SpawnedProcessReaperError({ operation: "spawn", cause: "sidecar unavailable" }), + ), + }); + yield* reaper.track(posixEntry); + yield* reaper.untrack(posixEntry.pid); + }).pipe(Effect.scoped), + ); + + it("reports stable sidecar operation context", () => { + const error = new SpawnedProcessReaperError({ operation: "write", cause: "broken pipe" }); + assert.strictEqual(error.operation, "write"); + assert.strictEqual(error.message, "Spawned process reaper sidecar write failed."); + }); + + it("builds the Windows process-tree argv without executing taskkill", () => { + assert.deepStrictEqual(windowsTaskkillArgs(42), ["/PID", "42", "/T", "/F"]); + }); +}); diff --git a/apps/server/src/provider/SpawnedProcessReaper.ts b/apps/server/src/provider/SpawnedProcessReaper.ts new file mode 100644 index 00000000000..9d0e17427a0 --- /dev/null +++ b/apps/server/src/provider/SpawnedProcessReaper.ts @@ -0,0 +1,452 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +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 Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; +import * as NodeChildProcess from "node:child_process"; + +const MAX_RESPAWN_ATTEMPTS = 3; +const RESPAWN_WINDOW_MS = 60_000; +const SIDECAR_RETRY_DELAY = "100 millis"; + +export type SpawnedProcessReaperPlatform = "posix" | "win32"; + +export interface SpawnedProcessReaperEntry { + readonly pid: number; + readonly pgid: number | null; + readonly platform: SpawnedProcessReaperPlatform; +} + +export class SpawnedProcessReaperError extends Schema.TaggedErrorClass()( + "SpawnedProcessReaperError", + { + cause: Schema.Defect(), + operation: Schema.Literals(["close", "spawn", "write"]), + }, +) { + override get message(): string { + return `Spawned process reaper sidecar ${this.operation} failed.`; + } +} + +export class SpawnedProcessReaper extends Context.Service< + SpawnedProcessReaper, + { + readonly track: (input: SpawnedProcessReaperEntry) => Effect.Effect; + readonly untrack: (pid: number) => Effect.Effect; + } +>()("t3/provider/SpawnedProcessReaper") {} + +export const SIDECAR_SOURCE = String.raw` +"use strict"; +const { spawnSync } = require("node:child_process"); +const entries = new Map(); +let shuttingDown = false; +let parentDeathHandled = false; +const parentPid = process.ppid; + +const killPosix = (entry, signal) => { + if (entry.pgid === null) { + try { + process.kill(entry.pid, signal); + } catch {} + return; + } + try { + process.kill(-entry.pgid, signal); + } catch { + try { + process.kill(entry.pid, signal); + } catch {} + } +}; + +const killWindows = (entry) => { + try { + spawnSync("taskkill", ["/PID", String(entry.pid), "/T", "/F"], { stdio: "ignore" }); + } catch {} +}; + +const reapAfterParentDeath = () => { + if (parentDeathHandled || shuttingDown) return; + parentDeathHandled = true; + const snapshot = [...entries.values()]; + let hasPosixEntry = false; + for (const entry of snapshot) { + if (entry.platform === "win32") { + killWindows(entry); + } else { + hasPosixEntry = true; + killPosix(entry, "SIGTERM"); + } + } + if (!hasPosixEntry) { + process.exit(0); + } + setTimeout(() => { + for (const entry of snapshot) { + if (entry.platform === "posix") killPosix(entry, "SIGKILL"); + } + process.exit(0); + }, 500); +}; + +const isParentDead = () => { + if (process.ppid !== parentPid) return true; + try { + process.kill(parentPid, 0); + return false; + } catch (error) { + return error && typeof error === "object" && "code" in error && error.code === "ESRCH"; + } +}; + +let parentDeathCheckStarted = false; +const checkParentDeath = () => { + if (parentDeathHandled || shuttingDown) return; + if (isParentDead()) { + reapAfterParentDeath(); + return; + } + setTimeout(checkParentDeath, 1000); +}; + +const handleLine = (line) => { + if (line.length === 0) return; + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if (message === null || typeof message !== "object") return; + if (message.op === "shutdown") { + shuttingDown = true; + process.exit(0); + } + const hasValidPgid = + message.pgid === undefined || message.pgid === null || Number.isFinite(message.pgid); + if (message.op === "track" && Number.isFinite(message.pid) && hasValidPgid) { + entries.set(message.pid, { + pid: message.pid, + pgid: message.pgid === undefined || message.pgid === null ? null : message.pgid, + platform: message.platform === "win32" ? "win32" : "posix", + }); + } else if (message.op === "untrack" && Number.isFinite(message.pid)) { + entries.delete(message.pid); + } +}; + +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\n")) !== -1) { + handleLine(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + } +}); +const handleStdinTermination = () => { + if (parentDeathCheckStarted || parentDeathHandled || shuttingDown) return; + parentDeathCheckStarted = true; + if (buffer.length > 0) { + const trailing = buffer; + buffer = ""; + handleLine(trailing); + } + if (!shuttingDown) checkParentDeath(); +}; + +// EOF without shutdown starts a parent-liveness check instead of assuming a broken pipe is fatal. +process.stdin.on("end", handleStdinTermination); +process.stdin.on("error", () => process.exit(1)); +`; + +export const windowsTaskkillArgs = (pid: number): [string, string, string, string] => [ + "/PID", + String(pid), + "/T", + "/F", +]; + +export interface SpawnedProcessReaperStdin { + readonly write: (chunk: string) => boolean; + readonly end: () => void; +} + +export interface SpawnedProcessReaperSidecar { + readonly stdin: SpawnedProcessReaperStdin; + readonly exitCode: Effect.Effect; + readonly terminate: Effect.Effect; +} + +export interface SpawnedProcessReaperSpawner { + readonly spawn: () => Effect.Effect; +} + +const makeNodeSidecarSpawner = ( + platform: NodeJS.Platform, + environment: NodeJS.ProcessEnv, +): SpawnedProcessReaperSpawner => ({ + spawn: () => + Effect.try({ + try: () => { + const child = NodeChildProcess.spawn(process.execPath, ["-e", SIDECAR_SOURCE], { + // Electron must launch the sidecar in Node mode so its stdin protocol works. + env: { ...environment, ELECTRON_RUN_AS_NODE: "1" }, + detached: platform !== "win32", + windowsHide: platform === "win32", + stdio: ["pipe", "ignore", "ignore"], + }); + const stdin = child.stdin; + if (stdin === null) { + child.kill(); + throw new Error("Spawned process reaper sidecar did not expose stdin"); + } + + const exitDeferred = Deferred.makeUnsafe(); + const completeExit = (code: number | null) => { + Deferred.doneUnsafe(exitDeferred, Effect.succeed(code)); + }; + child.once("exit", (code) => completeExit(code)); + child.once("error", () => completeExit(null)); + child.on("error", () => undefined); + stdin.on("error", () => undefined); + child.unref(); + + return { + stdin: { + write: (chunk) => stdin.write(chunk), + end: () => stdin.end(), + }, + exitCode: Deferred.await(exitDeferred), + terminate: Effect.sync(() => { + try { + child.kill(); + } catch { + // The sidecar may already have exited. + } + }), + } satisfies SpawnedProcessReaperSidecar; + }, + catch: (cause) => new SpawnedProcessReaperError({ operation: "spawn", cause }), + }), +}); + +export const makeSpawnedProcessReaper = Effect.fn("SpawnedProcessReaper.make")(function* ( + spawner: SpawnedProcessReaperSpawner, +): Effect.fn.Return { + const scope = yield* Effect.scope; + const mutex = yield* Semaphore.make(1); + const tracked = new Map(); + let sidecar: SpawnedProcessReaperSidecar | null = null; + let shuttingDown = false; + let respawnAttempts = 0; + let spawnRetryAttempts = 0; + let retryFiber: Fiber.Fiber | null = null; + const sidecarSpawnTimes = new WeakMap(); + + const logFailure = (operation: "close" | "spawn" | "write", _cause: unknown) => + Effect.logWarning("Spawned process reaper sidecar operation failed.", { operation }); + + const writeMessage = ( + candidate: SpawnedProcessReaperSidecar, + message: object, + ): Effect.Effect => + Effect.try({ + try: () => { + // @effect-diagnostics-next-line preferSchemaOverJson:off + candidate.stdin.write(`${JSON.stringify(message)}\n`); + return true; + }, + catch: (cause) => new SpawnedProcessReaperError({ operation: "write", cause }), + }).pipe(Effect.catch((cause) => logFailure("write", cause).pipe(Effect.as(false)))); + + const closeStdin = (candidate: SpawnedProcessReaperSidecar): Effect.Effect => + Effect.try({ + try: () => candidate.stdin.end(), + catch: (cause) => new SpawnedProcessReaperError({ operation: "close", cause }), + }).pipe( + Effect.catch((cause) => logFailure("close", cause)), + Effect.asVoid, + ); + + const trySpawnSidecar = Effect.fn("SpawnedProcessReaper.trySpawnSidecar")(function* () { + const candidate = yield* spawner + .spawn() + .pipe(Effect.catch((cause) => logFailure("spawn", cause).pipe(Effect.as(null)))); + if (candidate === null) return null; + sidecarSpawnTimes.set(candidate, yield* Clock.currentTimeMillis); + return candidate; + }); + + let ensureSidecar: () => Effect.Effect; + + const scheduleRetry = Effect.fn("SpawnedProcessReaper.scheduleRetry")(function* ( + delay: number | typeof SIDECAR_RETRY_DELAY, + resetSpawnBudget: boolean, + ) { + if (retryFiber !== null || shuttingDown || tracked.size === 0) { + return; + } + retryFiber = yield* Effect.sleep(delay).pipe( + Effect.andThen( + mutex.withPermit( + Effect.gen(function* () { + retryFiber = null; + if (resetSpawnBudget) spawnRetryAttempts = 0; + yield* ensureSidecar(); + }), + ), + ), + Effect.forkIn(scope), + ); + }); + + const handleSidecarFailure = Effect.fn("SpawnedProcessReaper.handleSidecarFailure")(function* ( + candidate: SpawnedProcessReaperSidecar, + ) { + if (sidecar !== candidate) return; + sidecar = null; + yield* candidate.terminate; + if (shuttingDown || tracked.size === 0) return; + const spawnedAt = sidecarSpawnTimes.get(candidate); + if ( + spawnedAt !== undefined && + (yield* Clock.currentTimeMillis) - spawnedAt > RESPAWN_WINDOW_MS + ) { + // A sidecar that lived past the window starts a fresh crash-loop budget. + respawnAttempts = 0; + } + respawnAttempts += 1; + if (respawnAttempts >= MAX_RESPAWN_ATTEMPTS) { + yield* Effect.logWarning( + `Spawned process reaper sidecar failed three times within ${RESPAWN_WINDOW_MS}ms; giving up`, + ); + return; + } + yield* ensureSidecar(); + }); + + const watchSidecar = (candidate: SpawnedProcessReaperSidecar): Effect.Effect => + candidate.exitCode.pipe( + Effect.flatMap(() => + mutex.withPermit( + Effect.gen(function* () { + if (sidecar !== candidate) return; + yield* handleSidecarFailure(candidate); + }), + ), + ), + ); + + const feedSidecar = Effect.fn("SpawnedProcessReaper.feedSidecar")(function* ( + candidate: SpawnedProcessReaperSidecar, + ) { + for (const entry of tracked.values()) { + if (!(yield* writeMessage(candidate, { op: "track", ...entry }))) { + yield* handleSidecarFailure(candidate); + return; + } + } + yield* watchSidecar(candidate).pipe(Effect.forkIn(scope), Effect.asVoid); + }); + + ensureSidecar = Effect.fn("SpawnedProcessReaper.ensureSidecar")(function* () { + if ( + sidecar !== null || + shuttingDown || + tracked.size === 0 || + respawnAttempts >= MAX_RESPAWN_ATTEMPTS || + spawnRetryAttempts >= MAX_RESPAWN_ATTEMPTS + ) { + return; + } + const candidate = yield* trySpawnSidecar(); + if (candidate === null) { + spawnRetryAttempts += 1; + if (spawnRetryAttempts >= MAX_RESPAWN_ATTEMPTS) { + yield* Effect.logWarning( + `Spawned process reaper sidecar failed three times within ${RESPAWN_WINDOW_MS}ms; pausing retries`, + ); + yield* scheduleRetry(RESPAWN_WINDOW_MS, true); + return; + } + yield* scheduleRetry(SIDECAR_RETRY_DELAY, false); + return; + } + if (shuttingDown) return; + spawnRetryAttempts = 0; + sidecar = candidate; + yield* feedSidecar(candidate); + }); + + const track = Effect.fn("SpawnedProcessReaper.track")(function* ( + entry: SpawnedProcessReaperEntry, + ) { + yield* mutex.withPermit( + Effect.gen(function* () { + if (shuttingDown) return; + tracked.set(entry.pid, entry); + const existingSidecar = sidecar; + yield* ensureSidecar(); + if (existingSidecar !== null && sidecar === existingSidecar) { + if (!(yield* writeMessage(existingSidecar, { op: "track", ...entry }))) { + yield* handleSidecarFailure(existingSidecar); + } + } + }), + ); + }); + + const untrack = Effect.fn("SpawnedProcessReaper.untrack")(function* (pid: number) { + yield* mutex.withPermit( + Effect.gen(function* () { + tracked.delete(pid); + if (tracked.size === 0) { + respawnAttempts = 0; + spawnRetryAttempts = 0; + } + const candidate = sidecar; + if (candidate !== null && !(yield* writeMessage(candidate, { op: "untrack", pid }))) { + yield* handleSidecarFailure(candidate); + } + }), + ); + }); + + const shutdown = mutex.withPermit( + Effect.gen(function* () { + shuttingDown = true; + const candidate = sidecar; + sidecar = null; + if (candidate !== null) { + const sent = yield* writeMessage(candidate, { op: "shutdown" }); + if (!sent) { + yield* candidate.terminate; + return; + } + yield* closeStdin(candidate); + } + }), + ); + + yield* Effect.addFinalizer(() => shutdown); + + return { track, untrack } satisfies SpawnedProcessReaper["Service"]; +}); + +const makeSpawnedProcessReaperLive = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const environment = yield* HostProcessEnvironment; + return yield* makeSpawnedProcessReaper(makeNodeSidecarSpawner(platform, environment)); +}); + +export const layer = Layer.effect(SpawnedProcessReaper, makeSpawnedProcessReaperLive); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index bbff99705d2..ac28d50a843 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -25,6 +25,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { OpenCode2Driver, type OpenCode2DriverEnv } from "./Drivers/OpenCode2Driver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -39,6 +40,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | OpenCode2DriverEnv | OpenCodeDriverEnv; /** @@ -52,5 +54,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray { ).pipe(Effect.provide(AlwaysRunTestLayer)), ); + it.effect("coalesces a refresh requested during the initial provider check", () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const releaseCheck = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( + Effect.flatMap(() => Deferred.await(releaseCheck)), + Effect.as(refreshedSnapshot), + ), + refreshInterval: "1 hour", + }); + + yield* Effect.yieldNow; + const refreshFiber = yield* provider.refresh.pipe(Effect.forkChild); + yield* Effect.yieldNow; + assert.strictEqual(yield* Ref.get(checkCalls), 1); + + yield* Deferred.succeed(releaseCheck, undefined); + assert.deepStrictEqual(yield* Fiber.join(refreshFiber), refreshedSnapshot); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }), + ).pipe(Effect.provide(AlwaysRunTestLayer)), + ); + it.effect("skips periodic provider refreshes without foreground provider-status demand", () => Effect.scoped( Effect.gen(function* () { @@ -355,6 +385,65 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(AlwaysRunTestLayer)), ); + it.effect("does not reapply stale refresh settings after a streamed update", () => + Effect.scoped( + Effect.gen(function* () { + const settingsChanges = yield* PubSub.unbounded(); + const settingsStreamSubscribed = yield* Deferred.make(); + const staleRefreshRead = yield* Deferred.make(); + const releaseStaleRefresh = yield* Deferred.make(); + const initialCheckDone = yield* Deferred.make(); + const streamedCheckDone = yield* Deferred.make(); + const getSettingsCalls = yield* Ref.make(0); + const checkCalls = yield* Ref.make(0); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Ref.updateAndGet(getSettingsCalls, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 2 + ? Deferred.succeed(staleRefreshRead, undefined).pipe( + Effect.andThen(Deferred.await(releaseStaleRefresh)), + Effect.as({ enabled: true }), + ) + : Effect.succeed({ enabled: true }), + ), + ), + streamSettings: Stream.unwrap( + PubSub.subscribe(settingsChanges).pipe( + Effect.tap(() => Deferred.succeed(settingsStreamSubscribed, undefined)), + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + ), + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.tap((count) => + count === 1 + ? Deferred.succeed(initialCheckDone, undefined) + : Deferred.succeed(streamedCheckDone, undefined), + ), + Effect.map((count) => (count === 1 ? refreshedSnapshot : refreshedSnapshotSecond)), + ), + refreshInterval: "1 hour", + }); + + yield* Deferred.await(settingsStreamSubscribed); + yield* Deferred.await(initialCheckDone); + + const staleRefresh = yield* provider.refresh.pipe(Effect.forkChild); + yield* Deferred.await(staleRefreshRead); + + yield* PubSub.publish(settingsChanges, { enabled: false }); + yield* Deferred.await(streamedCheckDone); + yield* Deferred.succeed(releaseStaleRefresh, undefined); + + assert.deepStrictEqual(yield* Fiber.join(staleRefresh), refreshedSnapshotSecond); + assert.strictEqual(yield* Ref.get(checkCalls), 2); + assert.deepStrictEqual(yield* provider.getSnapshot, refreshedSnapshotSecond); + }), + ).pipe(Effect.provide(AlwaysRunTestLayer)), + ); + it.effect("streams supplemental snapshot updates after the base provider check completes", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index d2b6b52e8f1..bd5fe73a4ba 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -22,6 +22,7 @@ import type { ServerProviderShape } from "./Services/ServerProvider.ts"; interface ProviderSnapshotState { readonly snapshot: ServerProvider; readonly enrichmentGeneration: number; + readonly refreshGeneration: number; } export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < @@ -57,6 +58,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const snapshotStateRef = yield* Ref.make({ snapshot: initialSnapshot, enrichmentGeneration: 0, + refreshGeneration: 0, }); const settingsRef = yield* Ref.make(initialSettings); const enrichmentFiberRef = yield* Ref.make | null>(null); @@ -112,13 +114,24 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const applySnapshotBase = Effect.fn("applySnapshot")(function* ( nextSettings: Settings, - options?: { readonly forceRefresh?: boolean }, + options?: { + readonly forceRefresh?: boolean; + readonly requestedGeneration?: number; + }, ) { const forceRefresh = options?.forceRefresh === true; const previousSettings = yield* Ref.get(settingsRef); + const currentState = yield* Ref.get(snapshotStateRef); + if ( + forceRefresh && + options?.requestedGeneration !== undefined && + currentState.refreshGeneration !== options.requestedGeneration + ) { + return currentState.snapshot; + } if (!forceRefresh && !input.haveSettingsChanged(previousSettings, nextSettings)) { yield* Ref.set(settingsRef, nextSettings); - return yield* Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)); + return currentState.snapshot; } const nextSnapshot = yield* input.checkProvider; @@ -131,6 +144,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( { snapshot: nextSnapshot, enrichmentGeneration: generation, + refreshGeneration: state.refreshGeneration + 1, }, ] as const; }); @@ -139,12 +153,20 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( yield* restartSnapshotEnrichment(nextSettings, nextSnapshot, nextGeneration); return nextSnapshot; }); - const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => - refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); + const applySnapshot = ( + nextSettings: Settings, + options?: { + readonly forceRefresh?: boolean; + readonly requestedGeneration?: number; + }, + ) => refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () { + const requestedGeneration = yield* Ref.get(snapshotStateRef).pipe( + Effect.map((state) => state.refreshGeneration), + ); const nextSettings = yield* input.getSettings; - return yield* applySnapshot(nextSettings, { forceRefresh: true }); + return yield* applySnapshot(nextSettings, { forceRefresh: true, requestedGeneration }); }); const hasProviderStatusDemand = Effect.gen(function* () { @@ -213,7 +235,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ), ).pipe(Effect.forkScoped); - yield* applySnapshot(initialSettings, { forceRefresh: true }).pipe( + yield* applySnapshot(initialSettings, { forceRefresh: true, requestedGeneration: 0 }).pipe( Effect.ignoreCause({ log: true }), Effect.forkScoped, ); diff --git a/apps/server/src/provider/opencode2Runtime.live.test.ts b/apps/server/src/provider/opencode2Runtime.live.test.ts new file mode 100644 index 00000000000..0baf8412cd1 --- /dev/null +++ b/apps/server/src/provider/opencode2Runtime.live.test.ts @@ -0,0 +1,87 @@ +import { assert, it } from "@effect/vitest"; +import { OpenCode2Settings as OpenCode2SettingsSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe } from "vite-plus/test"; + +import { checkOpenCode2ProviderStatus } from "./Layers/OpenCode2Provider.ts"; +import * as SpawnedProcessReaper from "./SpawnedProcessReaper.ts"; +import * as OpenCode2Runtime from "./opencode2Runtime.ts"; +import { OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; + +const decodeOpenCode2Settings = Schema.decodeEffect(OpenCode2SettingsSchema); + +/** + * Spawns a real `opencode2` binary, so it is gated the same way the other live + * orchestrator suites are. + * + * T3_OPENCODE2_LIVE=1 vp test src/provider/opencode2Runtime.live.test.ts + * + * The unit tests cover the banner parser against captured output. This covers + * what they cannot: that the layer wiring actually produces a spawned server + * and an authenticated client. Auth failures are the interesting case, because + * 2.x answers an unauthenticated request with 401 rather than a startup error, + * so a mis-wired client looks healthy until the first call. + */ +// `NodeServices.layer` is where the real child-process spawner comes from; it +// is what apps/server/src/bin.ts composes for the production runtime. +// `HostProcessPlatform` is a Context.Reference with a default, so it needs no +// layer of its own. +const layer = Layer.merge(OpenCode2Runtime.layer, OpenCodeRuntimeLive).pipe( + Layer.provide(SpawnedProcessReaper.layer), + Layer.provide(NodeServices.layer), +); + +describe.runIf(process.env.T3_OPENCODE2_LIVE === "1")("OpenCode 2 runtime (live)", () => { + it.live( + "spawns a server, reads both startup facts, and builds a client that authenticates", + () => + Effect.gen(function* () { + const runtime = yield* OpenCode2Runtime.OpenCode2Runtime; + const scope = yield* Scope.make(); + + const server = yield* runtime + .startOpenCode2ServerProcess({ binaryPath: "opencode2" }) + .pipe(Effect.provideService(Scope.Scope, scope)); + + assert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+$/); + assert.isAbove(server.password.length, 0); + + const client = runtime.createOpenCode2SdkClient({ + baseUrl: server.url, + directory: process.cwd(), + serverPassword: server.password, + }); + + // Any authenticated call proves the header is right; session.create is + // the one the adapter reaches for first. `client.v2.*` is the /api/* + // namespace, and the response carries its own `data` envelope. + const created = yield* OpenCode2Runtime.runOpenCode2Sdk("session.create", () => + client.v2.session.create({ location: { directory: process.cwd() } }), + ); + const sessionId = (created.data as { data?: { id?: string } } | undefined)?.data?.id; + assert.isString(sessionId, "session.create returned no id"); + + yield* Scope.close(scope, Effect.void as never).pipe(Effect.ignore); + }).pipe(Effect.provide(layer)), + { timeout: 60_000 }, + ); + + it.live( + "waits for startup inventory before publishing the provider snapshot", + () => + Effect.gen(function* () { + const settings = yield* decodeOpenCode2Settings({}); + const provider = yield* checkOpenCode2ProviderStatus(settings, process.cwd()); + + assert.strictEqual(provider.status, "ready"); + assert.match(provider.version ?? "", /^0\.0\.0-next-\d+$/); + assert.isAbove(provider.models.length, 0); + assert.isTrue(provider.models.some((model) => model.slug === "opencode/glm-5.2")); + }).pipe(Effect.provide(layer)), + { timeout: 60_000 }, + ); +}); diff --git a/apps/server/src/provider/opencode2Runtime.test.ts b/apps/server/src/provider/opencode2Runtime.test.ts new file mode 100644 index 00000000000..7f358149118 --- /dev/null +++ b/apps/server/src/provider/opencode2Runtime.test.ts @@ -0,0 +1,572 @@ +import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +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 Queue from "effect/Queue"; +import * as Sink from "effect/Sink"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + escalateOpenCode2ServerTermination, + isOpenCode2RuntimeError, + OpenCode2Runtime, + layer, + openCode2AuthorizationHeader, + parseOpenCode2Startup, + runOpenCode2Sdk, +} from "./opencode2Runtime.ts"; +import { SpawnedProcessReaper } from "./SpawnedProcessReaper.ts"; + +describe("parseOpenCode2Startup", () => { + // Exact banner emitted by @opencode-ai/cli 0.0.0-next-16339, captured by + // live-scenarios/tests/opencode2-drive-probe.mjs. + const banner = [ + "server listening on http://127.0.0.1:4711", + "server password Yb4ypFttKtPUvcKlnzQ4iOEUezhRpP4A", + "", + ].join("\n"); + + it("reads both facts out of the real banner", () => { + assert.deepStrictEqual(parseOpenCode2Startup(banner), { + url: "http://127.0.0.1:4711", + password: "Yb4ypFttKtPUvcKlnzQ4iOEUezhRpP4A", + }); + }); + + it("withholds a result until the password line has also arrived", () => { + // The two lines land in whatever chunks the pipe produces. Resolving on the + // URL alone would build a client with no credentials, and 2.x answers every + // unauthenticated request with 401 rather than an obvious startup failure. + assert.isNull(parseOpenCode2Startup("server listening on http://127.0.0.1:4711\n")); + assert.isNotNull(parseOpenCode2Startup(banner)); + }); + + it("withholds a result until the url line has also arrived", () => { + assert.isNull(parseOpenCode2Startup("server password abc123\n")); + }); + + it("does not match the 1.x banner", () => { + // 1.x prints `opencode server listening on ...` and never prints a + // password, so it must not satisfy the 2.x contract. + assert.isNull( + parseOpenCode2Startup( + [ + "Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.", + "opencode server listening on http://127.0.0.1:4607", + "", + ].join("\n"), + ), + ); + }); + + it("tolerates surrounding log noise", () => { + const noisy = [ + "[00:00:00.000] INFO (#1): starting", + "server listening on http://127.0.0.1:9999", + "some unrelated line", + "server password s3cr3t-token_ABC", + ].join("\n"); + assert.deepStrictEqual(parseOpenCode2Startup(noisy), { + url: "http://127.0.0.1:9999", + password: "s3cr3t-token_ABC", + }); + }); +}); + +describe("openCode2AuthorizationHeader", () => { + it("encodes the fixed opencode username with the minted password", () => { + assert.strictEqual( + openCode2AuthorizationHeader("s3cr3t"), + `Basic ${Buffer.from("opencode:s3cr3t", "utf8").toString("base64")}`, + ); + }); +}); + +describe("escalateOpenCode2ServerTermination", () => { + it.effect("sends TERM, waits for the grace period, then sends KILL", () => + Effect.gen(function* () { + const signals: Array = []; + const exit = yield* Deferred.make(); + const termination = yield* escalateOpenCode2ServerTermination( + (signal) => + Effect.sync(() => { + signals.push(signal); + }), + Deferred.await(exit), + Effect.succeed(true), + ).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + assert.deepStrictEqual(signals, ["SIGTERM"]); + + yield* TestClock.adjust("499 millis"); + assert.deepStrictEqual(signals, ["SIGTERM"]); + + yield* TestClock.adjust("1 millis"); + yield* Fiber.join(termination); + assert.deepStrictEqual(signals, ["SIGTERM", "SIGKILL"]); + }), + ); + + it.effect("returns immediately when the direct child exits and the group is gone", () => + Effect.gen(function* () { + const signals: Array = []; + const exit = yield* Deferred.make(); + const termination = yield* escalateOpenCode2ServerTermination( + (signal) => + Effect.sync(() => { + signals.push(signal); + }), + Deferred.await(exit), + Effect.succeed(false), + ).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + assert.deepStrictEqual(signals, ["SIGTERM"]); + + yield* Deferred.succeed(exit, undefined); + yield* Fiber.join(termination); + assert.deepStrictEqual(signals, ["SIGTERM"]); + }), + ); + + it.effect("kills surviving descendants after the direct child exits", () => + Effect.gen(function* () { + const signals: Array = []; + const exit = yield* Deferred.make(); + const termination = yield* escalateOpenCode2ServerTermination( + (signal) => + Effect.sync(() => { + signals.push(signal); + }), + Deferred.await(exit), + Effect.succeed(true), + ).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + yield* Deferred.succeed(exit, undefined); + yield* Effect.yieldNow; + assert.deepStrictEqual(signals, ["SIGTERM"]); + + yield* TestClock.adjust("500 millis"); + yield* Fiber.join(termination); + assert.deepStrictEqual(signals, ["SIGTERM", "SIGKILL"]); + }), + ); +}); + +describe("OpenCode2Runtime errors", () => { + it.effect("keeps SDK cause text out of the stable model-ready error", () => + Effect.gen(function* () { + const secret = "MODEL_STARTUP_SECRET"; + const cause = new Error(`Model unavailable: ${secret}`); + const error = yield* runOpenCode2Sdk("session.generate", async () => { + throw cause; + }).pipe(Effect.flip); + + assert.isTrue(isOpenCode2RuntimeError(error)); + assert.strictEqual(error.category, "model-unavailable"); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, secret); + }), + ); + + it.effect("normalizes authentication and network failures at the SDK boundary", () => + Effect.gen(function* () { + const authentication = yield* runOpenCode2Sdk("health.get", async () => { + throw new Error("401 Unauthorized: PRIVATE_RESPONSE"); + }).pipe(Effect.flip); + const network = yield* runOpenCode2Sdk("health.get", async () => { + throw new Error("fetch failed: ECONNREFUSED PRIVATE_ADDRESS"); + }).pipe(Effect.flip); + + assert.strictEqual(authentication.category, "authentication-failed"); + assert.strictEqual(network.category, "network-failed"); + assert.notInclude(authentication.message, "PRIVATE_RESPONSE"); + assert.notInclude(network.message, "PRIVATE_ADDRESS"); + }), + ); + + it.effect("does not classify an SDK 404 as a missing executable", () => + Effect.gen(function* () { + const error = yield* runOpenCode2Sdk("health.get", async () => { + throw new Error("NotFoundError: endpoint returned 404"); + }).pipe(Effect.flip); + + assert.strictEqual(error.category, "sdk-request-failed"); + }), + ); +}); + +describe("OpenCode2Runtime startup cleanup", () => { + it.effect("registers process cleanup before reaper tracking can block", () => + Effect.gen(function* () { + const signals: Array = []; + const trackStarted = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(42), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: (options) => + Effect.sync(() => { + if (options?.killSignal !== undefined) signals.push(options.killSignal); + }), + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.never, + stderr: Stream.never, + all: Stream.never, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.never, + }), + ), + ); + const startup = Effect.scoped( + Effect.gen(function* () { + const runtime = yield* OpenCode2Runtime; + yield* runtime.startOpenCode2ServerProcess({ binaryPath: "opencode2", port: 4_711 }); + }), + ).pipe( + Effect.provide(layer), + Effect.provideService(SpawnedProcessReaper, { + track: () => Deferred.succeed(trackStarted, undefined).pipe(Effect.andThen(Effect.never)), + untrack: () => Effect.void, + }), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HostProcessPlatform, "win32"), + ); + const startupFiber = yield* startup.pipe(Effect.forkChild); + + yield* Deferred.await(trackStarted); + yield* Fiber.interrupt(startupFiber); + + assert.deepStrictEqual(signals, ["SIGTERM"]); + }), + ); + + it.effect("keeps draining stdout after startup settles", () => + Effect.gen(function* () { + const output = yield* Queue.unbounded(); + const encoder = new TextEncoder(); + const banner = [ + "server listening on http://127.0.0.1:4711", + "server password drained-password", + "", + ].join("\n"); + yield* Queue.offer(output, encoder.encode(banner)); + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(42), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.fromQueue(output), + stderr: Stream.never, + all: Stream.never, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.never, + }), + ), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* OpenCode2Runtime; + yield* runtime.startOpenCode2ServerProcess({ + binaryPath: "opencode2", + port: 4_711, + }); + yield* Queue.offer(output, encoder.encode("post-ready diagnostic\n")); + yield* Effect.yieldNow; + assert.strictEqual(yield* Queue.size(output), 0); + }), + ).pipe( + Effect.provide(layer), + Effect.provideService(SpawnedProcessReaper, { + track: () => Effect.void, + untrack: () => Effect.void, + }), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HostProcessPlatform, "win32"), + ); + }), + ); + + it.effect("retains startup credentials across more than the diagnostic buffer", () => + Effect.gen(function* () { + const output = yield* Queue.unbounded(); + const encoder = new TextEncoder(); + yield* Queue.offer( + output, + encoder.encode(`server listening on http://127.0.0.1:4711\n${"x".repeat(17_000)}\n`), + ); + yield* Queue.offer(output, encoder.encode("server password retained-password\n")); + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(42), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.fromQueue(output), + stderr: Stream.never, + all: Stream.never, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.never, + }), + ), + ); + + const credentials = yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* OpenCode2Runtime; + return yield* runtime.startOpenCode2ServerProcess({ + binaryPath: "opencode2", + port: 4_711, + }); + }), + ).pipe( + Effect.provide(layer), + Effect.provideService(SpawnedProcessReaper, { + track: () => Effect.void, + untrack: () => Effect.void, + }), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HostProcessPlatform, "win32"), + ); + + assert.strictEqual(credentials.url, "http://127.0.0.1:4711"); + assert.strictEqual(credentials.password, "retained-password"); + }), + ); + + it.effect("rejects a ready banner from a process that already exited", () => + Effect.gen(function* () { + const banner = [ + "server listening on http://127.0.0.1:4711", + "server password expired-password", + "", + ].join("\n"); + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(42), + exitCode: Effect.never, + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(banner)), + stderr: Stream.never, + all: Stream.never, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.never, + }), + ), + ); + + const error = yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* OpenCode2Runtime; + return yield* runtime + .startOpenCode2ServerProcess({ binaryPath: "opencode2", port: 4_711 }) + .pipe(Effect.flip); + }), + ).pipe( + Effect.provide(layer), + Effect.provideService(SpawnedProcessReaper, { + track: () => Effect.void, + untrack: () => Effect.void, + }), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HostProcessPlatform, "win32"), + ); + + assert.strictEqual(error.category, "startup-exited"); + assert.notInclude(error.message, "expired-password"); + }), + ); + + it.effect("does not expose a startup password when the server exits before ready", () => + Effect.gen(function* () { + const password = "MINTED_STARTUP_PASSWORD"; + const exit = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(42), + exitCode: Deferred.await(exit), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(`server password ${password}\n`)), + stderr: Stream.never, + all: Stream.never, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.never, + }), + ), + ); + const startup = Effect.scoped( + Effect.gen(function* () { + const runtime = yield* OpenCode2Runtime; + return yield* runtime + .startOpenCode2ServerProcess({ + binaryPath: "opencode2", + port: 4_711, + }) + .pipe(Effect.flip); + }), + ).pipe( + Effect.provide(layer), + Effect.provideService(SpawnedProcessReaper, { + track: () => Effect.void, + untrack: () => Effect.void, + }), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HostProcessPlatform, "win32"), + ); + const startupFiber = yield* startup.pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* Deferred.succeed(exit, ChildProcessSpawner.ExitCode(1)); + const error = yield* Fiber.join(startupFiber); + + assert.isTrue(isOpenCode2RuntimeError(error)); + assert.strictEqual(error.category, "startup-exited"); + assert.strictEqual(error.exitCode, 1); + assert.isUndefined(error.cause); + assert.notInclude(error.message, password); + }), + ); + + it.effect("recognizes the package placeholder without retaining its output", () => + Effect.gen(function* () { + const exit = yield* Deferred.make(); + const output = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(42), + exitCode: Deferred.await(exit), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.fromEffect(Deferred.await(output)), + stderr: Stream.empty, + all: Stream.never, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.never, + }), + ), + ); + const startup = Effect.scoped( + Effect.gen(function* () { + const runtime = yield* OpenCode2Runtime; + return yield* runtime + .startOpenCode2ServerProcess({ binaryPath: "opencode2", port: 4_711 }) + .pipe(Effect.flip); + }), + ).pipe( + Effect.provide(layer), + Effect.provideService(SpawnedProcessReaper, { + track: () => Effect.void, + untrack: () => Effect.void, + }), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HostProcessPlatform, "win32"), + ); + const startupFiber = yield* startup.pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* Deferred.succeed(exit, ChildProcessSpawner.ExitCode(1)); + yield* Effect.yieldNow; + yield* Deferred.succeed( + output, + new TextEncoder().encode("Error: @opencode-ai/cli's postinstall script was not run.\n"), + ); + const error = yield* Fiber.join(startupFiber); + + assert.isTrue(isOpenCode2RuntimeError(error)); + assert.strictEqual(error.category, "placeholder-binary"); + assert.notInclude(error.message, "postinstall script was not run"); + }), + ); + + it.effect("reaps a spawned server whose startup banner times out", () => + Effect.gen(function* () { + const signals: Array = []; + const killOptions: Array = []; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(42), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: (options) => + Effect.sync(() => { + killOptions.push(options); + if (options?.killSignal !== undefined) { + signals.push(options.killSignal); + } + }), + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.never, + stderr: Stream.never, + all: Stream.never, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.never, + }), + ), + ); + const startup = Effect.gen(function* () { + const runtime = yield* OpenCode2Runtime; + const runtimeScope = yield* Scope.make(); + const startupFiber = yield* runtime + .startOpenCode2ServerProcess({ + binaryPath: "opencode2", + port: 4_711, + timeoutMs: 100, + }) + .pipe(Effect.provideService(Scope.Scope, runtimeScope), Effect.flip, Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("100 millis"); + const error = yield* Fiber.join(startupFiber); + assert.strictEqual(error.category, "startup-timeout"); + assert.deepStrictEqual(signals, ["SIGTERM"]); + + yield* Scope.close(runtimeScope, Exit.void); + assert.deepStrictEqual(signals, ["SIGTERM"]); + }).pipe( + Effect.provide(layer), + Effect.provideService(SpawnedProcessReaper, { + track: () => Effect.void, + untrack: () => Effect.void, + }), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HostProcessPlatform, "win32"), + ); + yield* startup; + assert.deepStrictEqual(signals, ["SIGTERM"]); + assert.deepStrictEqual(killOptions, [ + { killSignal: "SIGTERM", forceKillAfter: "500 millis" }, + ]); + }), + ); +}); diff --git a/apps/server/src/provider/opencode2Runtime.ts b/apps/server/src/provider/opencode2Runtime.ts new file mode 100644 index 00000000000..f9834ac1e8c --- /dev/null +++ b/apps/server/src/provider/opencode2Runtime.ts @@ -0,0 +1,604 @@ +/** + * Runtime for OpenCode 2.x ("OpenCode 2"), which is a different server than + * the 1.x one `opencodeRuntime.ts` drives, not a newer build of it. + * + * Three differences make a separate runtime necessary rather than a flag: + * + * 1. **Startup banner.** 1.x prints `opencode server listening on `; + * 2.x prints `server listening on `. The 1.x prefix match never fires + * and the spawn times out. + * 2. **Mandatory auth.** 1.x serves unauthenticated and warns about it. 2.x + * always mints a password, prints it on stdout beside the URL, and 401s + * without it. So startup has to resolve *two* facts, not one. + * 3. **Route surface.** 2.x serves only `/api/*`. The SDK is versioned to + * match and is pinned here under the `@opencode-ai/sdk-next` alias, since + * two majors of one package name cannot coexist. Its `client.v2.*` + * namespace is the `/api/*` one; `client.session.*` is the legacy surface + * and 404s against a 2.x server. + * + * `live-scenarios/tests/opencode2-drive-probe.mjs` in the parent workspace + * exercises this contract against a real binary and fails first if 2.x moves. + * Spawned-server lifetime is guaranteed by the scope finalizer's process-group + * kill, which returns immediately when the group is gone and escalates after + * 500ms when descendants remain, plus the stdin-EOF watchdog sidecar that + * reaps on confirmed parent death. If many sessions tear down sequentially and + * overrun the desktop's 2-second grace, the sidecar still reaps the remainder + * via pipe EOF. + */ +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk-next/v2"; +import * as NetService from "@t3tools/shared/Net"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { openCodeRuntimeErrorDetail } from "./opencodeRuntime.ts"; +import * as SpawnedProcessReaper from "./SpawnedProcessReaper.ts"; + +const DEFAULT_OPENCODE2_SERVER_TIMEOUT_MS = 30_000; +const DEFAULT_HOSTNAME = "127.0.0.1"; +const MAX_OPENCODE2_STARTUP_OUTPUT_CHARS = 16_384; +const OPENCODE2_STARTUP_DRAIN_TIMEOUT = "100 millis"; +const wallClock = Clock.Clock.defaultValue(); + +const withOpenCode2WallClock = (effect: Effect.Effect): Effect.Effect => + Effect.provideService(effect, Clock.Clock, wallClock); + +export const OpenCode2RuntimeOperation = Schema.Literals([ + "agent.list", + "connectToOpenCode2Server", + "event.subscribe", + "generate.text", + "health.get", + "integration.list", + "mcp.list", + "message.list", + "model.list", + "session.compact", + "session.create", + "session.form.reply", + "session.fork", + "session.generate", + "session.get", + "session.instructions.entry.put", + "session.interrupt", + "session.pending.list", + "session.permission.reply", + "session.prompt", + "session.question.reply", + "session.remove", + "session.revert.commit", + "session.revert.stage", + "session.switchAgent", + "session.switchModel", + "session.wait", + "shell.create", + "shell.list", + "shell.output", + "shell.remove", + "startOpenCode2ServerProcess", +]); +export type OpenCode2RuntimeOperation = typeof OpenCode2RuntimeOperation.Type; + +export const OpenCode2RuntimeErrorCategory = Schema.Literals([ + "authentication-failed", + "binary-not-found", + "event-subscription-failed", + "external-server-password-required", + "mcp-connect-failed", + "mcp-connect-timeout", + "missing-response-payload", + "model-unavailable", + "network-failed", + "placeholder-binary", + "port-allocation-failed", + "quarantined-binary", + "replay-boundary", + "sdk-request-failed", + "server-spawn-failed", + "session-remove-failed", + "startup-exited", + "startup-failed", + "startup-timeout", +]); +export type OpenCode2RuntimeErrorCategory = typeof OpenCode2RuntimeErrorCategory.Type; + +export class OpenCode2RuntimeError extends Schema.TaggedErrorClass()( + "OpenCode2RuntimeError", + { + category: OpenCode2RuntimeErrorCategory, + cause: Schema.optional(Schema.Defect()), + exitCode: Schema.optionalKey(Schema.Number), + operation: OpenCode2RuntimeOperation, + timeoutMs: Schema.optionalKey(Schema.Number), + }, +) { + override get message(): string { + const exitContext = this.exitCode === undefined ? "" : `, exit code ${this.exitCode}`; + const timeoutContext = this.timeoutMs === undefined ? "" : `, timeout ${this.timeoutMs}ms`; + return `OpenCode 2 ${this.operation} failed (${this.category}${exitContext}${timeoutContext}).`; + } +} + +export const isOpenCode2RuntimeError = Schema.is(OpenCode2RuntimeError); + +function openCode2SdkErrorCategoryFromText( + cause: unknown, +): Extract< + OpenCode2RuntimeErrorCategory, + "authentication-failed" | "model-unavailable" | "network-failed" | "sdk-request-failed" +> { + const detail = openCodeRuntimeErrorDetail(cause).toLowerCase(); + if (detail.startsWith("model unavailable:")) return "model-unavailable"; + if ( + detail.includes("401") || + detail.includes("403") || + detail.includes("unauthorized") || + detail.includes("forbidden") + ) { + return "authentication-failed"; + } + if ( + detail.includes("econnrefused") || + detail.includes("enotfound") || + detail.includes("fetch failed") || + detail.includes("networkerror") || + detail.includes("socket hang up") || + detail.includes("timed out") || + detail.includes("timeout") + ) { + return "network-failed"; + } + return "sdk-request-failed"; +} + +function openCode2ExecutableErrorCategoryFromText( + cause: unknown, +): Extract< + OpenCode2RuntimeErrorCategory, + "binary-not-found" | "placeholder-binary" | "quarantined-binary" | "server-spawn-failed" +> { + const detail = openCodeRuntimeErrorDetail(cause).toLowerCase(); + if (detail.includes("postinstall")) return "placeholder-binary"; + if (detail.includes("quarantine")) return "quarantined-binary"; + if (detail.includes("enoent") || detail.includes("notfound")) return "binary-not-found"; + return "server-spawn-failed"; +} + +export const runOpenCode2Sdk = ( + operation: OpenCode2RuntimeOperation, + fn: () => Promise, +): Effect.Effect => + Effect.tryPromise({ + try: fn, + catch: (cause) => + new OpenCode2RuntimeError({ + operation, + category: openCode2SdkErrorCategoryFromText(cause), + cause, + }), + }).pipe(Effect.withSpan(`opencode2.${operation}`)); + +/** Both facts the 2.x startup banner carries. Neither is optional. */ +export interface OpenCode2ServerCredentials { + readonly url: string; + readonly password: string; +} + +export interface OpenCode2ServerProcess extends OpenCode2ServerCredentials { + readonly exitCode: Effect.Effect; + readonly isRunning: Effect.Effect; +} + +export interface OpenCode2ServerConnection extends OpenCode2ServerCredentials { + readonly exitCode: Effect.Effect | null; + readonly external: boolean; +} + +export class OpenCode2Runtime extends Context.Service< + OpenCode2Runtime, + { + /** + * Spawn a local 2.x server. Lifetime is bound to the caller's scope, so the + * child dies when that scope closes. + */ + readonly startOpenCode2ServerProcess: (input: { + readonly binaryPath: string; + readonly environment?: NodeJS.ProcessEnv; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; + }) => Effect.Effect; + /** + * Connect to an externally-managed server, or spawn one. An external server + * must carry its own password: 2.x has no unauthenticated mode, so there is + * nothing to fall back to. + */ + readonly connectToOpenCode2Server: (input: { + readonly binaryPath: string; + readonly serverUrl?: string | null; + readonly serverPassword?: string | null; + readonly environment?: NodeJS.ProcessEnv; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; + }) => Effect.Effect; + readonly createOpenCode2SdkClient: (input: { + readonly baseUrl: string; + readonly directory: string; + readonly serverPassword: string; + }) => OpencodeClient; + } +>()("t3/provider/opencode2Runtime") {} + +/** + * Read the URL and password out of accumulated server output. + * + * Returns `null` until *both* are present: they arrive on separate lines and a + * chunked read can see one without the other, and a client built without the + * password gets 401 on every call. Deliberately not anchored to line start — + * 2.x has changed the surrounding banner text before. + * + * @internal exported for tests + */ +export function parseOpenCode2Startup(output: string): OpenCode2ServerCredentials | null { + const url = output.match(/server listening on\s+(https?:\/\/\S+)/)?.[1]; + const password = output.match(/server password\s+(\S+)/)?.[1]; + return url && password ? { url, password } : null; +} + +/** The header the 2.x server checks. Username is fixed; only the password varies. */ +export function openCode2AuthorizationHeader(password: string): string { + return `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; +} + +/** + * The 2.x server resolves a session with no variant to a synthetic variant + * literally named "default" and stamps that id on the session and its + * messages, so this id means "let the server decide" and must never be sent + * as an explicit variant: it is not in any model's `variants` list, and 2.x + * silently drops the next prompt (user message recorded, no assistant reply) + * when the bound variant is unknown. + */ +export const OPENCODE2_DEFAULT_VARIANT = "default"; + +export function normalizeOpenCode2Variant(variant: string | undefined): string | undefined { + return variant === OPENCODE2_DEFAULT_VARIANT ? undefined : variant; +} + +/** + * Synthetic agent-option id meaning "defer to the Build/Plan toggle". The + * Agent descriptor only appears when custom primary agents exist, listing + * this sentinel plus the customs; the native `build`/`plan` pair is owned by + * the interaction-mode toggle. A real agent named "auto" would collide and is + * excluded from the descriptor. + */ +export const OPENCODE2_AUTO_AGENT = "auto"; + +export function escalateOpenCode2ServerTermination( + kill: (signal: NodeJS.Signals) => Effect.Effect, + awaitExit: Effect.Effect, + isProcessGroupAlive: Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + yield* kill("SIGTERM"); + const exited = yield* awaitExit.pipe(Effect.as(true), Effect.timeoutOption("500 millis")); + if (Option.isSome(exited)) { + if (!(yield* isProcessGroupAlive)) return; + yield* Effect.sleep("500 millis"); + if (!(yield* isProcessGroupAlive)) return; + yield* kill("SIGKILL"); + return; + } + if (!(yield* isProcessGroupAlive)) return; + yield* kill("SIGKILL"); + }); +} + +export const make = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const netService = yield* NetService.NetService; + const hostPlatform = yield* HostProcessPlatform; + const reaper = yield* SpawnedProcessReaper.SpawnedProcessReaper; + + const resolveCommand = (command: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => + resolveSpawnCommand(command, args, env ? { env } : {}); + + const startOpenCode2ServerProcess: OpenCode2Runtime["Service"]["startOpenCode2ServerProcess"] = ( + input, + ) => + Effect.gen(function* () { + const runtimeScope = yield* Effect.scope; + const processScope = yield* Scope.make(); + yield* Scope.addFinalizer( + runtimeScope, + Scope.close(processScope, Exit.void).pipe(Effect.ignore), + ); + const hostname = input.hostname ?? DEFAULT_HOSTNAME; + const port = + input.port ?? + (yield* netService.findAvailablePort(0).pipe( + Effect.mapError( + (cause) => + new OpenCode2RuntimeError({ + operation: "startOpenCode2ServerProcess", + category: "port-allocation-failed", + cause, + }), + ), + )); + const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE2_SERVER_TIMEOUT_MS; + const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; + const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); + + const spawnOpenCode2Server = spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + detached: hostPlatform !== "win32", + shell: spawnCommand.shell, + ...(input.environment === undefined ? {} : { env: input.environment }), + extendEnv: input.environment === undefined, + }), + ) + .pipe( + Effect.provideService(Scope.Scope, processScope), + Effect.mapError((cause) => { + return new OpenCode2RuntimeError({ + operation: "startOpenCode2ServerProcess", + category: openCode2ExecutableErrorCategoryFromText(cause), + cause, + }); + }), + ); + + const { child, processId, isProcessTreeRunning } = yield* Effect.uninterruptible( + spawnOpenCode2Server.pipe( + Effect.flatMap((child) => { + const processId = Number(child.pid); + const isOpenCode2ProcessGroupAlive = Effect.sync(() => { + try { + process.kill(-processId, 0); + return true; + } catch (cause) { + return !( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ESRCH" + ); + } + }); + const isProcessTreeRunning = + hostPlatform === "win32" + ? child.isRunning.pipe(Effect.orElseSucceed(() => false)) + : isOpenCode2ProcessGroupAlive; + const killOpenCode2ProcessGroup = (signal: NodeJS.Signals) => + hostPlatform === "win32" + ? child + .kill({ killSignal: signal, forceKillAfter: "500 millis" }) + .pipe(Effect.asVoid, Effect.ignore) + : Effect.sync(() => { + try { + process.kill(-processId, signal); + } catch { + // The direct child may already have exited after starting + // the server. The group kill still owns its descendants. + } + }); + const terminateOpenCode2Server = + hostPlatform === "win32" + ? child + .kill({ killSignal: "SIGTERM", forceKillAfter: "500 millis" }) + .pipe(Effect.asVoid, Effect.ignore) + : escalateOpenCode2ServerTermination( + killOpenCode2ProcessGroup, + child.exitCode.pipe(Effect.asVoid, Effect.ignore), + isOpenCode2ProcessGroupAlive, + ); + return Scope.addFinalizer( + processScope, + terminateOpenCode2Server.pipe(Effect.andThen(reaper.untrack(processId))), + ).pipe(Effect.as({ child, processId, isProcessTreeRunning })); + }), + ), + ); + yield* reaper.track({ + pid: processId, + pgid: hostPlatform === "win32" ? null : processId, + platform: hostPlatform === "win32" ? "win32" : "posix", + }); + const startupOutputRef = yield* Ref.make<{ + readonly output: string | null; + readonly password: string | null; + readonly url: string | null; + readonly failureCategory: OpenCode2RuntimeErrorCategory | null; + }>({ output: "", password: null, url: null, failureCategory: null }); + const readyDeferred = yield* Deferred.make< + OpenCode2ServerCredentials, + OpenCode2RuntimeError + >(); + + // The banner is two lines and the split between them lands wherever the + // pipe happens to break. Keep a bounded parser buffer only until both + // startup facts are found, then continue draining without retaining logs. + const absorb = (chunk: string) => + Ref.modify(startupOutputRef, (previous) => { + if (previous.output === null) return [null, previous] as const; + const combined = `${previous.output}${chunk}`; + const url = + previous.url ?? combined.match(/server listening on\s+(https?:\/\/\S+)/)?.[1] ?? null; + const password = + previous.password ?? combined.match(/server password\s+(\S+)/)?.[1] ?? null; + const parsed = url !== null && password !== null ? { url, password } : null; + const output = combined.slice(-MAX_OPENCODE2_STARTUP_OUTPUT_CHARS); + const category = openCode2ExecutableErrorCategoryFromText(combined); + return [ + parsed, + { + output: parsed === null ? output : null, + password, + url, + failureCategory: + category === "server-spawn-failed" ? previous.failureCategory : category, + }, + ] as const; + }).pipe( + Effect.flatMap((parsed) => + parsed === null + ? Effect.void + : Deferred.succeed(readyDeferred, parsed).pipe(Effect.asVoid), + ), + ); + + const stdoutFiber = yield* child.stdout.pipe( + Stream.decodeText(), + Stream.runForEach(absorb), + Effect.ignore, + Effect.forkIn(processScope), + ); + // 2.x has printed the banner to stderr across builds, so watch both. + const stderrFiber = yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach(absorb), + Effect.ignore, + Effect.forkIn(processScope), + ); + + yield* child.exitCode.pipe( + Effect.flatMap((code) => + Effect.gen(function* () { + const exitCode = Number(code); + yield* Effect.all([Fiber.await(stdoutFiber), Fiber.await(stderrFiber)], { + discard: true, + }).pipe( + Effect.timeoutOption(OPENCODE2_STARTUP_DRAIN_TIMEOUT), + Effect.asVoid, + withOpenCode2WallClock, + ); + const startupFailureCategory = (yield* Ref.get(startupOutputRef)).failureCategory; + yield* Deferred.fail( + readyDeferred, + new OpenCode2RuntimeError({ + operation: "startOpenCode2ServerProcess", + category: startupFailureCategory ?? "startup-exited", + exitCode, + }), + ).pipe(Effect.ignore); + }), + ), + Effect.ignore, + Effect.forkIn(processScope), + ); + + const readyExit = yield* Effect.exit( + Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), + ); + + if (Exit.isFailure(readyExit)) { + yield* Scope.close(processScope, readyExit); + const squashed = Cause.squash(readyExit.cause); + if (isOpenCode2RuntimeError(squashed)) return yield* squashed; + return yield* new OpenCode2RuntimeError({ + operation: "startOpenCode2ServerProcess", + category: "startup-failed", + cause: squashed, + }); + } + + const ready = readyExit.value; + if (Option.isNone(ready)) { + yield* Scope.close(processScope, Exit.void); + return yield* new OpenCode2RuntimeError({ + operation: "startOpenCode2ServerProcess", + category: "startup-timeout", + timeoutMs, + }); + } + + const isRunning = yield* isProcessTreeRunning; + if (!isRunning) { + yield* Scope.close(processScope, Exit.void); + return yield* new OpenCode2RuntimeError({ + operation: "startOpenCode2ServerProcess", + category: "startup-exited", + }); + } + + return { + url: ready.value.url, + password: ready.value.password, + exitCode: child.exitCode.pipe( + Effect.map(Number), + Effect.orElseSucceed(() => 0), + ), + isRunning: isProcessTreeRunning, + } satisfies OpenCode2ServerProcess; + }); + + const connectToOpenCode2Server: OpenCode2Runtime["Service"]["connectToOpenCode2Server"] = ( + input, + ) => { + const serverUrl = input.serverUrl?.trim(); + if (serverUrl) { + const serverPassword = input.serverPassword?.trim(); + if (!serverPassword) { + return new OpenCode2RuntimeError({ + operation: "connectToOpenCode2Server", + category: "external-server-password-required", + }); + } + // Not ours, so no lifetime is attached to the caller's scope. + return Effect.succeed({ + url: serverUrl, + password: serverPassword, + exitCode: null, + external: true, + }); + } + + return startOpenCode2ServerProcess({ + binaryPath: input.binaryPath, + ...(input.environment !== undefined ? { environment: input.environment } : {}), + ...(input.port !== undefined ? { port: input.port } : {}), + ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + }).pipe( + Effect.map((server) => ({ + url: server.url, + password: server.password, + exitCode: server.exitCode, + external: false, + })), + ); + }; + + const createOpenCode2SdkClient: OpenCode2Runtime["Service"]["createOpenCode2SdkClient"] = ( + input, + ) => + createOpencodeClient({ + baseUrl: input.baseUrl, + directory: input.directory, + headers: { Authorization: openCode2AuthorizationHeader(input.serverPassword) }, + throwOnError: true, + }); + + return { + startOpenCode2ServerProcess, + connectToOpenCode2Server, + createOpenCode2SdkClient, + } satisfies OpenCode2Runtime["Service"]; +}); + +export const layer = Layer.effect(OpenCode2Runtime, make).pipe(Layer.provide(NetService.layer)); diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 8937844f613..0f19764c6dc 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -8,7 +8,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; -import { HttpClient } from "effect/unstable/http"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { createProviderVersionAdvisory, enrichProviderSnapshotWithVersionAdvisory, @@ -37,6 +37,14 @@ const packageToolUpdate = makePackageManagedProviderMaintenanceResolver({ homebrewFormula: "package-tool", nativeUpdate: null, }); +const nextPackageToolUpdate = makePackageManagedProviderMaintenanceResolver({ + provider: driver("nextPackageTool"), + npmPackageName: "@example/package-tool", + npmDistTag: "next", + requiresInstallScripts: true, + homebrewFormula: null, + nativeUpdate: null, +}); const nativePackageToolUpdate = makePackageManagedProviderMaintenanceResolver({ provider: driver("nativePackageTool"), npmPackageName: "@example/native-package-tool", @@ -68,12 +76,12 @@ const staticToolUpdate = makeStaticProviderMaintenanceResolver( updateLockKey: "static-tool", }), ); -const installedPackageToolProvider: ServerProvider = { - instanceId: ProviderInstanceId.make("packageTool"), - driver: driver("packageTool"), +const installedNextPackageToolProvider: ServerProvider = { + instanceId: ProviderInstanceId.make("nextPackageTool"), + driver: driver("nextPackageTool"), enabled: true, installed: true, - version: "1.0.0", + version: "0.0.0-next-16691", status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-04-10T00:00:00.000Z", @@ -89,7 +97,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ProviderVersionCache, new Map([ [ - "@example/package-tool", + "@example/package-tool@latest", { expiresAt: Number.MAX_SAFE_INTEGER, version: "9.9.9", @@ -109,10 +117,50 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ), ); + it.effect("resolves npm dist-tags with release-channel-isolated cache entries", () => { + const requestedUrls: Array = []; + const versionCache = new Map([ + [ + "@example/package-tool@latest", + { + expiresAt: Number.MAX_SAFE_INTEGER, + version: "9.9.9", + }, + ], + ]); + + return Effect.gen(function* () { + const nextVersion = yield* resolveLatestProviderVersion(nextPackageToolUpdate.resolve()); + const latestVersion = yield* resolveLatestProviderVersion(packageToolUpdate.resolve()); + + expect(nextVersion).toBe("0.0.0-next-16694"); + expect(latestVersion).toBe("9.9.9"); + expect(requestedUrls).toEqual(["https://registry.npmjs.org/%40example%2Fpackage-tool/next"]); + expect(versionCache.get("@example/package-tool@next")?.version).toBe("0.0.0-next-16694"); + }).pipe( + Effect.provideService(ProviderVersionCache, versionCache), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requestedUrls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + { version: "0.0.0-next-16694" }, + { headers: { "content-type": "application/json" } }, + ), + ), + ); + }), + ), + ); + }); + it.effect("does not fetch latest provider versions when update checks are disabled", () => enrichProviderSnapshotWithVersionAdvisory( - installedPackageToolProvider, - packageToolUpdate.resolve(), + installedNextPackageToolProvider, + nextPackageToolUpdate.resolve(), { enableProviderUpdateChecks: false, }, @@ -127,8 +175,10 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { Effect.map((provider) => { expect(provider.versionAdvisory).toMatchObject({ status: "unknown", - currentVersion: "1.0.0", + currentVersion: "0.0.0-next-16691", latestVersion: null, + updateCommand: "npm install -g @example/package-tool@next", + canUpdate: true, checkedAt: "2026-04-10T00:00:00.000Z", }); }), @@ -182,6 +232,23 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }); }); + it("compares prerelease versions on the configured npm release channel", () => { + expect( + createProviderVersionAdvisory({ + driver: driver("nextPackageTool"), + currentVersion: "0.0.0-next-16691", + latestVersion: "0.0.0-next-16694", + maintenanceCapabilities: nextPackageToolUpdate.resolve(), + }), + ).toMatchObject({ + status: "behind_latest", + currentVersion: "0.0.0-next-16691", + latestVersion: "0.0.0-next-16694", + updateCommand: "npm install -g @example/package-tool@next", + canUpdate: true, + }); + }); + it("keeps update commands owned by provider maintenance capabilities", () => { expect(staticToolUpdate.resolve()).toEqual({ provider: driver("staticTool"), diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 8645f9f943c..7137b0afb65 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -14,8 +14,8 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; -const LATEST_VERSION_TIMEOUT_MS = 4_000; +const PROVIDER_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; +const PROVIDER_VERSION_TIMEOUT_MS = 4_000; const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; const compactEnv = (input: Record>): NodeJS.ProcessEnv => @@ -40,6 +40,7 @@ const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() export interface ProviderMaintenanceCapabilities { readonly provider: ProviderDriverKind; readonly packageName: string | null; + readonly npmDistTag?: string; readonly update: ProviderMaintenanceCommandAction | null; } @@ -66,6 +67,8 @@ export interface ProviderMaintenanceCapabilitiesResolver { export interface PackageManagedProviderMaintenanceDefinition { readonly provider: ProviderDriverKind; readonly npmPackageName: string; + readonly npmDistTag?: string; + readonly requiresInstallScripts?: boolean; readonly homebrewFormula: string | null; readonly nativeUpdate: { readonly executable: string; @@ -86,7 +89,7 @@ export const ProviderVersionCache = Context.Reference new Map(), }, ); -const NpmLatestVersionResponse = Schema.Struct({ +const NpmDistTagVersionResponse = Schema.Struct({ version: Schema.optional(Schema.String), }); @@ -97,6 +100,7 @@ function nonEmptyString(value: unknown): string | null { export function makeProviderMaintenanceCapabilities(input: { readonly provider: ProviderDriverKind; readonly packageName: string | null; + readonly npmDistTag?: string | undefined; readonly updateExecutable: string | null; readonly updateArgs: ReadonlyArray; readonly updateLockKey: string | null; @@ -113,6 +117,7 @@ export function makeProviderMaintenanceCapabilities(input: { return { provider: input.provider, packageName: input.packageName, + ...(input.npmDistTag ? { npmDistTag: input.npmDistTag } : {}), update, }; } @@ -120,24 +125,31 @@ export function makeProviderMaintenanceCapabilities(input: { export function makeManualOnlyProviderMaintenanceCapabilities(input: { readonly provider: ProviderDriverKind; readonly packageName: string | null; + readonly npmDistTag?: string | undefined; }): ProviderMaintenanceCapabilities { return makeProviderMaintenanceCapabilities({ provider: input.provider, packageName: input.packageName, + npmDistTag: input.npmDistTag, updateExecutable: null, updateArgs: [], updateLockKey: null, }); } +function npmPackageSpec(definition: PackageManagedProviderMaintenanceDefinition): string { + return `${definition.npmPackageName}@${definition.npmDistTag ?? "latest"}`; +} + function makeNpmGlobalProviderMaintenanceCapabilities( definition: PackageManagedProviderMaintenanceDefinition, ): ProviderMaintenanceCapabilities { return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName: definition.npmPackageName, + npmDistTag: definition.npmDistTag, updateExecutable: "npm", - updateArgs: ["install", "-g", `${definition.npmPackageName}@latest`], + updateArgs: ["install", "-g", npmPackageSpec(definition)], updateLockKey: "npm-global", }); } @@ -148,8 +160,11 @@ function makeBunGlobalProviderMaintenanceCapabilities( return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName: definition.npmPackageName, + npmDistTag: definition.npmDistTag, updateExecutable: "bun", - updateArgs: ["i", "-g", `${definition.npmPackageName}@latest`], + updateArgs: definition.requiresInstallScripts + ? ["add", "-g", "--trust", npmPackageSpec(definition)] + : ["i", "-g", npmPackageSpec(definition)], updateLockKey: "bun-global", }); } @@ -160,8 +175,14 @@ function makePnpmGlobalProviderMaintenanceCapabilities( return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName: definition.npmPackageName, + npmDistTag: definition.npmDistTag, updateExecutable: "pnpm", - updateArgs: ["add", "-g", `${definition.npmPackageName}@latest`], + updateArgs: [ + "add", + "-g", + npmPackageSpec(definition), + ...(definition.requiresInstallScripts ? [`--allow-build=${definition.npmPackageName}`] : []), + ], updateLockKey: "pnpm-global", }); } @@ -172,8 +193,13 @@ function makeVitePlusGlobalProviderMaintenanceCapabilities( return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName: definition.npmPackageName, + npmDistTag: definition.npmDistTag, updateExecutable: "vp", - updateArgs: ["i", "-g", definition.npmPackageName], + updateArgs: [ + "i", + "-g", + definition.npmDistTag ? npmPackageSpec(definition) : definition.npmPackageName, + ], updateLockKey: "vite-plus-global", }); } @@ -185,12 +211,14 @@ function makeHomebrewProviderMaintenanceCapabilities( return makeManualOnlyProviderMaintenanceCapabilities({ provider: definition.provider, packageName: definition.npmPackageName, + npmDistTag: definition.npmDistTag, }); } return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName: definition.npmPackageName, + npmDistTag: definition.npmDistTag, updateExecutable: "brew", updateArgs: ["upgrade", definition.homebrewFormula], updateLockKey: "homebrew", @@ -207,6 +235,7 @@ function makeNativeProviderMaintenanceCapabilities( return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName: definition.npmPackageName, + npmDistTag: definition.npmDistTag, updateExecutable: definition.nativeUpdate.executable, updateArgs: definition.nativeUpdate.args, updateLockKey: definition.nativeUpdate.lockKey, @@ -315,6 +344,7 @@ export function resolvePackageManagedProviderMaintenance( return makeManualOnlyProviderMaintenanceCapabilities({ provider: definition.provider, packageName: definition.npmPackageName, + npmDistTag: definition.npmDistTag, }); } @@ -420,13 +450,16 @@ export function createProviderVersionAdvisory(input: { }; } -const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (packageName: string) { +const fetchNpmDistTagVersion = Effect.fn("fetchNpmDistTagVersion")(function* ( + packageName: string, + distTag: string, +) { const client = yield* HttpClient.HttpClient; const request = HttpClientRequest.get( - `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, + `https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(distTag)}`, ).pipe(HttpClientRequest.setHeader("accept", "application/json")); const response = yield* client.execute(request).pipe( - Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS), + Effect.timeoutOption(PROVIDER_VERSION_TIMEOUT_MS), Effect.orElseSucceed(() => Option.none()), ); if (Option.isNone(response)) { @@ -437,12 +470,16 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack return null; } const payload = yield* httpResponse.json.pipe( - Effect.flatMap(Schema.decodeUnknownEffect(NpmLatestVersionResponse)), + Effect.flatMap(Schema.decodeUnknownEffect(NpmDistTagVersionResponse)), Effect.orElseSucceed(() => null), ); return payload ? nonEmptyString(payload.version) : null; }); +function providerVersionCacheKey(packageName: string, distTag: string): string { + return `${packageName}@${distTag}`; +} + export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVersion")(function* ( maintenanceCapabilities: ProviderMaintenanceCapabilities, ) { @@ -450,17 +487,19 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers if (!packageName) { return null; } + const distTag = maintenanceCapabilities.npmDistTag ?? "latest"; + const cacheKey = providerVersionCacheKey(packageName, distTag); - const latestVersionCache = yield* ProviderVersionCache; - const cached = latestVersionCache.get(packageName); + const providerVersionCache = yield* ProviderVersionCache; + const cached = providerVersionCache.get(cacheKey); const now = DateTime.toEpochMillis(yield* DateTime.now); if (cached && cached.expiresAt > now) { return cached.version; } - const version = yield* fetchNpmLatestVersion(packageName); - latestVersionCache.set(packageName, { - expiresAt: now + LATEST_VERSION_CACHE_TTL_MS, + const version = yield* fetchNpmDistTagVersion(packageName, distTag); + providerVersionCache.set(cacheKey, { + expiresAt: now + PROVIDER_VERSION_CACHE_TTL_MS, version, }); return version; diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e741a7a2c1d..f0910532aee 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -138,6 +138,23 @@ export function parseGenericCliVersion(output: string): string | null { return match?.[1] ?? null; } +export function inferOpenCodeDefaultVariant( + providerID: string, + variants: ReadonlyArray, +): string | undefined { + if (variants.length === 1) { + return variants[0]; + } + if (providerID === "anthropic" || providerID.startsWith("google")) { + return variants.includes("high") ? "high" : undefined; + } + if (providerID === "openai" || providerID === "opencode") { + if (variants.includes("medium")) return "medium"; + if (variants.includes("high")) return "high"; + } + return undefined; +} + export function providerModelsFromSettings( builtInModels: ReadonlyArray, customModels: ReadonlyArray, diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 07f67cd7de8..9fa859102db 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -23,6 +23,7 @@ const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); const CODEX_DRIVER = ProviderDriverKind.make("codex"); const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); +const OPENCODE2_DRIVER = ProviderDriverKind.make("opencode2"); const makeProvider = ( provider: ProviderDriverKind, @@ -205,6 +206,194 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { ); }); + it("drops legacy native agent choices from a cached OpenCode 2 snapshot", () => { + const legacyCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "variant", + label: "Reasoning", + type: "select", + currentValue: "default", + options: [ + { id: "default", label: "Default", isDefault: true }, + { id: "high", label: "High" }, + ], + }, + { + id: "agent", + label: "Agent", + type: "select", + currentValue: "build", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + { id: "release-captain", label: "Release Captain" }, + ], + }, + ], + }); + const cachedProvider = makeProvider(OPENCODE2_DRIVER, { + models: [ + { + slug: "opencode/test", + name: "Test", + isCustom: false, + capabilities: legacyCapabilities, + }, + ], + }); + const fallbackProvider = makeProvider(OPENCODE2_DRIVER, { + installed: false, + status: "warning", + auth: { status: "unknown" }, + models: [], + }); + + const hydrated = hydrateCachedProvider({ cachedProvider, fallbackProvider }); + + assert.deepStrictEqual(hydrated.models[0]?.capabilities?.optionDescriptors, [ + { + id: "variant", + label: "Reasoning", + type: "select", + currentValue: "high", + options: [{ id: "high", label: "High", isDefault: true }], + }, + { + id: "agent", + label: "Agent", + type: "select", + currentValue: "auto", + options: [ + { id: "auto", label: "Auto (Build/Plan)" }, + { id: "release-captain", label: "Release Captain" }, + ], + }, + ]); + }); + + it("drops a cached OpenCode 2 Agent descriptor when the native pair is incomplete", () => { + const cachedProvider = makeProvider(OPENCODE2_DRIVER, { + models: [ + { + slug: "opencode/test", + name: "Test", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { + id: "agent", + label: "Agent", + type: "select", + currentValue: "build", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: "release-captain", label: "Release Captain" }, + ], + }, + ], + }), + }, + ], + }); + const fallbackProvider = makeProvider(OPENCODE2_DRIVER, { models: [] }); + + const hydrated = hydrateCachedProvider({ cachedProvider, fallbackProvider }); + + assert.deepStrictEqual(hydrated.models[0]?.capabilities?.optionDescriptors, []); + }); + + it("drops a cached OpenCode 2 Agent descriptor with only plan and a custom agent", () => { + const cachedProvider = makeProvider(OPENCODE2_DRIVER, { + models: [ + { + slug: "opencode/test", + name: "Test", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { + id: "agent", + label: "Agent", + type: "select", + currentValue: "plan", + options: [ + { id: "plan", label: "Plan", isDefault: true }, + { id: "release-captain", label: "Release Captain" }, + ], + }, + ], + }), + }, + ], + }); + const fallbackProvider = makeProvider(OPENCODE2_DRIVER, { models: [] }); + + const hydrated = hydrateCachedProvider({ cachedProvider, fallbackProvider }); + + assert.deepStrictEqual(hydrated.models[0]?.capabilities?.optionDescriptors, []); + }); + + it("drops a native-only cached OpenCode 2 Agent descriptor", () => { + const cachedProvider = makeProvider(OPENCODE2_DRIVER, { + models: [ + { + slug: "opencode/test", + name: "Test", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { + id: "agent", + label: "Agent", + type: "select", + currentValue: "build", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ], + }, + ], + }), + }, + ], + }); + const fallbackProvider = makeProvider(OPENCODE2_DRIVER, { models: [] }); + + const hydrated = hydrateCachedProvider({ cachedProvider, fallbackProvider }); + + assert.deepStrictEqual(hydrated.models[0]?.capabilities?.optionDescriptors, []); + }); + + it("does not sanitize similarly shaped non-OpenCode 2 cache entries", () => { + const capabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "agent", + label: "Agent", + type: "select", + currentValue: "build", + options: [{ id: "build", label: "Build", isDefault: true }], + }, + ], + }); + const cachedProvider = makeProvider(CODEX_DRIVER, { + models: [ + { + slug: "gpt-test", + name: "GPT Test", + isCustom: false, + capabilities, + }, + ], + }); + const fallbackProvider = makeProvider(CODEX_DRIVER, { models: [] }); + + const hydrated = hydrateCachedProvider({ cachedProvider, fallbackProvider }); + + assert.deepStrictEqual(hydrated.models[0]?.capabilities, capabilities); + }); + it("rejects cached snapshots that are not correlated to the fallback instance", () => { const fallbackCodex = makeProvider(CODEX_DRIVER, { models: [ diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 2fe0424b4f5..ca64fe9adcb 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -1,6 +1,7 @@ import { - type ProviderDriverKind, + ProviderDriverKind, type ProviderInstanceId, + type ProviderOptionDescriptor, type ServerProvider, ServerProvider as ServerProviderSchema, } from "@t3tools/contracts"; @@ -11,6 +12,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { inferOpenCodeDefaultVariant } from "./providerSnapshot.ts"; const decodeProviderStatusCache = Schema.decodeUnknownEffect( Schema.fromJsonString(ServerProviderSchema), @@ -24,6 +26,88 @@ const mergeProviderModels = ( return [...fallbackModels, ...cachedModels.filter((model) => !fallbackSlugs.has(model.slug))]; }; +const sanitizeCachedModels = ( + provider: ServerProvider, +): ReadonlyArray => { + if (provider.driver !== ProviderDriverKind.make("opencode2")) { + return provider.models; + } + return provider.models.map((model) => { + const descriptors = model.capabilities?.optionDescriptors; + if (!descriptors) return model; + let changed = false; + const nextDescriptors: Array = []; + for (const descriptor of descriptors) { + if (descriptor.type !== "select") { + nextDescriptors.push(descriptor); + continue; + } + + if (descriptor.id === "variant") { + const containsDefaultSentinel = descriptor.options.some( + (option) => option.id === "default", + ); + if (!containsDefaultSentinel) { + nextDescriptors.push(descriptor); + continue; + } + changed = true; + const options = descriptor.options + .filter((option) => option.id !== "default") + .map(({ isDefault: _isDefault, ...option }) => option); + if (options.length === 0) continue; + const providerID = model.slug.split("/", 1)[0] ?? ""; + const optionIDs = options.map((option) => option.id); + const defaultVariant = + inferOpenCodeDefaultVariant(providerID, optionIDs) ?? + optionIDs.find((variant) => variant === "medium") ?? + optionIDs.find((variant) => variant === "high") ?? + optionIDs.find((variant) => variant !== "none") ?? + optionIDs[0]!; + nextDescriptors.push({ + ...descriptor, + currentValue: defaultVariant, + options: options.map((option) => + option.id === defaultVariant ? { ...option, isDefault: true } : option, + ), + }); + continue; + } + + if (descriptor.id !== "agent") { + nextDescriptors.push(descriptor); + continue; + } + const containsBuildAgent = descriptor.options.some((option) => option.id === "build"); + const containsPlanAgent = descriptor.options.some((option) => option.id === "plan"); + if (!containsBuildAgent && !containsPlanAgent) { + nextDescriptors.push(descriptor); + continue; + } + changed = true; + if (!containsBuildAgent || !containsPlanAgent) continue; + const customOptions = descriptor.options + .filter((option) => option.id !== "auto" && option.id !== "build" && option.id !== "plan") + .map(({ isDefault: _isDefault, ...option }) => option); + if (customOptions.length > 0) { + nextDescriptors.push({ + ...descriptor, + currentValue: "auto", + options: [{ id: "auto", label: "Auto (Build/Plan)" }, ...customOptions], + }); + } + } + if (!changed) return model; + return { + ...model, + capabilities: { + ...model.capabilities, + optionDescriptors: nextDescriptors, + }, + }; + }); +}; + export const orderProviderSnapshots = ( providers: ReadonlyArray, ): ReadonlyArray => @@ -57,9 +141,10 @@ export const hydrateCachedProvider = (input: { } const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider; + const cachedModels = sanitizeCachedModels(input.cachedProvider); const hydratedProvider: ServerProvider = { ...fallbackWithoutMessage, - models: mergeProviderModels(input.fallbackProvider.models, input.cachedProvider.models), + models: mergeProviderModels(input.fallbackProvider.models, cachedModels), installed: input.cachedProvider.installed, version: input.cachedProvider.version, status: input.cachedProvider.status, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b2a35e90b00..07947a3495b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,6 +25,7 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; +import * as OpenCode2Runtime from "./provider/opencode2Runtime.ts"; import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; @@ -33,6 +34,7 @@ import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; +import * as SpawnedProcessReaper from "./provider/SpawnedProcessReaper.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import { layer as ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -348,6 +350,10 @@ const RuntimeCoreDependenciesLive = RuntimeCoreDependenciesBaseLive.pipe( // no longer transitively provides it. Exposing it at the runtime level // keeps a single Live for all opencode consumers. Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + // `OpenCode2Driver` is a separate server runtime, not a binary-path + // variant of the 1.x one, so it needs its own Live alongside it. + Layer.provideMerge(OpenCode2Runtime.layer), + Layer.provideMerge(SpawnedProcessReaper.layer), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(ProjectFaviconResolverLayerLive), diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.live.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.live.test.ts new file mode 100644 index 00000000000..285ea0d9c8c --- /dev/null +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.live.test.ts @@ -0,0 +1,78 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ChatAttachment, OpenCode2Settings, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { describe } from "vite-plus/test"; + +import * as OpenCode2Runtime from "../provider/opencode2Runtime.ts"; +import * as SpawnedProcessReaper from "../provider/SpawnedProcessReaper.ts"; +import { makeOpenCode2TextGeneration } from "./OpenCode2TextGeneration.ts"; + +const decodeOpenCode2Settings = Schema.decodeUnknownEffect(OpenCode2Settings); +const decodeChatAttachment = Schema.decodeUnknownEffect(ChatAttachment); +const layer = OpenCode2Runtime.layer.pipe( + Layer.provide(SpawnedProcessReaper.layer), + Layer.provide(NodeServices.layer), +); + +describe.runIf(process.env.T3_OPENCODE2_LIVE === "1")("OpenCode 2 text generation (live)", () => { + it.live( + "generates text and attachment-metadata titles with the selected model", + () => + Effect.scoped( + Effect.gen(function* () { + const settings = yield* decodeOpenCode2Settings({ + binaryPath: "opencode2", + }); + const textGeneration = yield* makeOpenCode2TextGeneration(settings); + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Add deterministic OpenCode 2 text generation coverage.", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode2-live"), + model: "opencode/glm-5.2", + }, + }); + + assert.isAbove(result.title.length, 0); + assert.isAtMost(result.title.length, 50); + + const attachment = yield* decodeChatAttachment({ + type: "image", + id: "opencode2-live-12345678-1234-1234-1234-123456789abc", + name: "green-pixel.png", + mimeType: "image/png", + sizeBytes: 68, + }); + const attachmentResult = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread for a green image attachment test.", + attachments: [attachment], + modelSelection: { + instanceId: ProviderInstanceId.make("opencode2-live"), + model: "opencode/glm-5.2", + }, + }); + + assert.isAbove(attachmentResult.title.length, 0); + assert.isAtMost(attachmentResult.title.length, 50); + + const agentResult = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread for an agent-bound generation test.", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode2-live"), + model: "opencode/glm-5.2", + options: [{ id: "agent", value: "build" }], + }, + }); + + assert.isAbove(agentResult.title.length, 0); + assert.isAtMost(agentResult.title.length, 50); + }), + ).pipe(Effect.provide(layer)), + { timeout: 120_000 }, + ); +}); diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts new file mode 100644 index 00000000000..cc6f60e902c --- /dev/null +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts @@ -0,0 +1,573 @@ +import type { OpencodeClient } from "@opencode-ai/sdk-next/v2"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { + ChatAttachment, + OpenCode2Settings, + ProviderInstanceId, + TextGenerationError, +} from "@t3tools/contracts"; +import * as NetService from "@t3tools/shared/Net"; +import * as Duration from "effect/Duration"; +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 TestClock from "effect/testing/TestClock"; +import { beforeEach, expect } from "vite-plus/test"; + +import * as ServerConfig from "../config.ts"; +import * as OpenCode2Runtime from "../provider/opencode2Runtime.ts"; +import * as OpenCode2TextGeneration from "./OpenCode2TextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; + +const runtimeMock = { + state: { + startCalls: [] as string[], + startError: undefined as OpenCode2Runtime.OpenCode2RuntimeError | undefined, + runningServers: [] as boolean[], + closeCalls: [] as string[], + connectCalls: [] as Array<{ + binaryPath: string; + serverUrl?: string | null; + serverPassword?: string | null; + }>, + clientConnections: [] as Array<{ + baseUrl: string; + directory: string; + serverPassword: string; + }>, + generateRequests: [] as Array>, + sessionCreateRequests: [] as Array>, + sessionGenerateRequests: [] as Array>, + sessionRemoveRequests: [] as Array>, + generateResponse: { + data: { + data: { + text: JSON.stringify({ + subject: "Add OpenCode 2 generation", + body: "Use the stateless generation endpoint.", + }), + }, + }, + } as unknown, + generateErrors: [] as Array, + sessionGenerateError: undefined as unknown, + }, + reset() { + this.state.startCalls.length = 0; + this.state.startError = undefined; + this.state.runningServers.length = 0; + this.state.closeCalls.length = 0; + this.state.connectCalls.length = 0; + this.state.clientConnections.length = 0; + this.state.generateRequests.length = 0; + this.state.sessionCreateRequests.length = 0; + this.state.sessionGenerateRequests.length = 0; + this.state.sessionRemoveRequests.length = 0; + this.state.generateResponse = { + data: { + data: { + text: JSON.stringify({ + subject: "Add OpenCode 2 generation", + body: "Use the stateless generation endpoint.", + }), + }, + }, + }; + this.state.generateErrors.length = 0; + this.state.sessionGenerateError = undefined; + }, +}; + +const OpenCode2RuntimeTestDouble: OpenCode2Runtime.OpenCode2Runtime["Service"] = { + startOpenCode2ServerProcess: ({ binaryPath }) => + Effect.gen(function* () { + if (runtimeMock.state.startError !== undefined) { + return yield* runtimeMock.state.startError; + } + const url = `http://127.0.0.1:${4_500 + runtimeMock.state.startCalls.length}`; + runtimeMock.state.startCalls.push(binaryPath); + const serverIndex = runtimeMock.state.runningServers.push(true) - 1; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls.push(url); + }), + ); + return { + url, + password: `password-${runtimeMock.state.startCalls.length}`, + exitCode: Effect.never, + isRunning: Effect.sync(() => runtimeMock.state.runningServers[serverIndex] === true), + }; + }), + connectToOpenCode2Server: ({ binaryPath, serverUrl, serverPassword }) => + Effect.gen(function* () { + runtimeMock.state.connectCalls.push({ + binaryPath, + ...(serverUrl === undefined ? {} : { serverUrl }), + ...(serverPassword === undefined ? {} : { serverPassword }), + }); + if (serverUrl && !serverPassword?.trim()) { + return yield* new OpenCode2Runtime.OpenCode2RuntimeError({ + operation: "connectToOpenCode2Server", + category: "external-server-password-required", + }); + } + return { + url: serverUrl ?? "http://127.0.0.1:4500", + password: serverPassword ?? "password-1", + exitCode: null, + external: Boolean(serverUrl), + }; + }), + createOpenCode2SdkClient: ({ baseUrl, directory, serverPassword }) => { + runtimeMock.state.clientConnections.push({ baseUrl, directory, serverPassword }); + return { + v2: { + generate: { + text: async (parameters: Record) => { + runtimeMock.state.generateRequests.push(parameters); + const error = runtimeMock.state.generateErrors.shift(); + if (error !== undefined) throw error; + return runtimeMock.state.generateResponse; + }, + }, + session: { + create: async (parameters: Record) => { + runtimeMock.state.sessionCreateRequests.push(parameters); + return { data: { data: { id: "temporary-session" } } }; + }, + generate: async (parameters: Record) => { + runtimeMock.state.sessionGenerateRequests.push(parameters); + if (runtimeMock.state.sessionGenerateError !== undefined) { + throw runtimeMock.state.sessionGenerateError; + } + return runtimeMock.state.generateResponse; + }, + remove: async (parameters: Record) => { + runtimeMock.state.sessionRemoveRequests.push(parameters); + return { data: undefined }; + }, + }, + }, + } as unknown as OpencodeClient; + }, +}; + +const DEFAULT_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("opencode2"), + model: "opencode/big-pickle", +}; +const DEFAULT_COMMIT_INPUT = { + cwd: process.cwd(), + branch: "feature/opencode2-generation", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_MODEL_SELECTION, +}; +const DEFAULT_SETTINGS = Schema.decodeSync(OpenCode2Settings)({ + binaryPath: "fake-opencode2", +}); +const EXTERNAL_SETTINGS = Schema.decodeSync(OpenCode2Settings)({ + binaryPath: "fake-opencode2", + serverUrl: "http://127.0.0.1:9998", + serverPassword: "external-secret", +}); +const EXTERNAL_SETTINGS_WITHOUT_PASSWORD = Schema.decodeSync(OpenCode2Settings)({ + binaryPath: "fake-opencode2", + serverUrl: "http://127.0.0.1:9998", +}); +const IMAGE_ATTACHMENT = Schema.decodeSync(ChatAttachment)({ + type: "image", + id: "thread-12345678-1234-1234-1234-123456789abc", + name: "picker.png", + mimeType: "image/png", + sizeBytes: 123, +}); +const OPENCODE2_TEXT_GENERATION_IDLE_TTL_MS = 30_000; + +const OpenCode2TextGenerationTestLayer = Layer.succeed( + OpenCode2Runtime.OpenCode2Runtime, + OpenCode2RuntimeTestDouble, +).pipe( + Layer.provideMerge( + ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-opencode2-text-generation-test-", + }), + ), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(NodeServices.layer), +); + +function withOpenCode2TextGeneration( + settings: OpenCode2Settings, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, +) { + return Effect.gen(function* () { + const textGeneration = yield* OpenCode2TextGeneration.makeOpenCode2TextGeneration(settings); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +const advanceIdleClock = Effect.gen(function* () { + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(OPENCODE2_TEXT_GENERATION_IDLE_TTL_MS + 1)); + yield* Effect.yieldNow; +}); + +beforeEach(() => { + runtimeMock.reset(); +}); + +it.layer(OpenCode2TextGenerationTestLayer)("OpenCode2TextGeneration", (it) => { + it.effect("generates with Big Pickle, reuses the local server, and closes it after idling", () => + withOpenCode2TextGeneration(DEFAULT_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const input = { + ...DEFAULT_COMMIT_INPUT, + policy: { + kind: "custom" as const, + commitInstructions: "Use concise release-note wording.", + inferRepositoryConventions: false, + }, + }; + const first = yield* textGeneration.generateCommitMessage(input); + const second = yield* textGeneration.generateCommitMessage(input); + + expect(first).toEqual({ + subject: "Add OpenCode 2 generation", + body: "Use the stateless generation endpoint.", + }); + expect(second).toEqual(first); + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode2"]); + expect(runtimeMock.state.generateRequests).toHaveLength(2); + expect(runtimeMock.state.generateRequests[0]).toMatchObject({ + location: { directory: process.cwd() }, + model: { providerID: "opencode", id: "big-pickle" }, + }); + expect(runtimeMock.state.generateRequests[0]?.prompt).toContain( + "Use concise release-note wording.", + ); + expect(runtimeMock.state.closeCalls).toEqual([]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4500"]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("replaces the shared local server when its process has exited", () => + withOpenCode2TextGeneration(DEFAULT_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const first = yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_INPUT); + runtimeMock.state.runningServers[0] = false; + const [second, third] = yield* Effect.all( + [ + textGeneration.generateCommitMessage(DEFAULT_COMMIT_INPUT), + textGeneration.generateCommitMessage(DEFAULT_COMMIT_INPUT), + ], + { concurrency: "unbounded" }, + ); + + expect(second).toEqual(first); + expect(third).toEqual(first); + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode2", "fake-opencode2"]); + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4500"]); + expect(runtimeMock.state.clientConnections.map((connection) => connection.baseUrl)).toEqual( + ["http://127.0.0.1:4500", "http://127.0.0.1:4501", "http://127.0.0.1:4501"], + ); + }), + ), + ); + + it.effect("uses the configured authenticated server without spawning", () => + withOpenCode2TextGeneration(EXTERNAL_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_INPUT); + + expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.connectCalls).toEqual([ + { + binaryPath: "fake-opencode2", + serverUrl: "http://127.0.0.1:9998", + serverPassword: "external-secret", + }, + ]); + expect(runtimeMock.state.clientConnections).toEqual([ + { + baseUrl: "http://127.0.0.1:9998", + directory: process.cwd(), + serverPassword: "external-secret", + }, + ]); + }), + ), + ); + + it.effect("keeps startup failures static while preserving their immediate cause", () => + withOpenCode2TextGeneration(DEFAULT_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const secret = "OPEN_CODE_2_STARTUP_SECRET"; + const cause = new Error(secret); + runtimeMock.state.startError = new OpenCode2Runtime.OpenCode2RuntimeError({ + operation: "startOpenCode2ServerProcess", + category: "startup-failed", + cause, + }); + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode 2 server startup failed."); + expect(error.message).not.toContain(secret); + expect(error.cause).toMatchObject({ + _tag: "OpenCode2RuntimeError", + cause, + }); + }), + ), + ); + + it.effect("rejects an external server without its required password", () => + withOpenCode2TextGeneration(EXTERNAL_SETTINGS_WITHOUT_PASSWORD, (textGeneration) => + Effect.gen(function* () { + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_INPUT) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.message).toContain("OpenCode 2 server connection failed."); + expect(error.cause).toMatchObject({ + _tag: "OpenCode2RuntimeError", + category: "external-server-password-required", + }); + expect(runtimeMock.state.clientConnections).toEqual([]); + }), + ), + ); + + it.effect("generates PR content and branch names through the shared endpoint", () => + withOpenCode2TextGeneration(DEFAULT_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.generateResponse = { + data: { + data: { + text: '{"title":"Add OpenCode 2 text generation","body":"## Summary\\n\\n- Add generation"}', + }, + }, + }; + const pr = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feature/opencode2-generation", + commitSummary: "Add generation", + changeRequestTemplate: "## Change summary\n\n## Validation", + diffSummary: "2 files changed", + diffPatch: "diff --git a/a b/a", + policy: { + kind: "custom", + changeRequestInstructions: "Call out the rollout plan.", + inferRepositoryConventions: false, + }, + modelSelection: DEFAULT_MODEL_SELECTION, + }); + + runtimeMock.state.generateResponse = { + data: { + data: { + text: '{"branch":"opencode2-text-generation"}', + }, + }, + }; + const branch = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Add OpenCode 2 text generation.", + modelSelection: DEFAULT_MODEL_SELECTION, + }); + + expect(pr).toEqual({ + title: "Add OpenCode 2 text generation", + body: "## Summary\n\n- Add generation", + }); + expect(runtimeMock.state.generateRequests[0]?.prompt).toContain( + "Call out the rollout plan.", + ); + expect(runtimeMock.state.generateRequests[0]?.prompt).toContain("## Change summary"); + expect(branch).toEqual({ branch: "opencode2-text-generation" }); + }), + ), + ); + + it.effect("uses prior title context when regenerating a thread title", () => + withOpenCode2TextGeneration(DEFAULT_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.generateResponse = { + data: { + data: { + text: '{"title":"Repair OpenCode 2 status handling"}', + }, + }, + }; + + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "The status probe should redact server credentials.", + previousTitle: "Investigate provider status", + modelSelection: DEFAULT_MODEL_SELECTION, + }); + + expect(result).toEqual({ title: "Repair OpenCode 2 status handling" }); + expect(runtimeMock.state.generateRequests[0]?.prompt).toContain( + 'The previous title was "Investigate provider status".', + ); + expect(runtimeMock.state.generateRequests[0]?.prompt).toContain( + "Come up with a new title that better represents the current state of the thread.", + ); + }), + ), + ); + + it.effect("rejects model slugs without a provider prefix before calling the SDK", () => + withOpenCode2TextGeneration(DEFAULT_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const error = yield* textGeneration + .generateCommitMessage({ + ...DEFAULT_COMMIT_INPUT, + modelSelection: { + ...DEFAULT_MODEL_SELECTION, + model: "big-pickle", + }, + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.message).toContain("must use the 'provider/model' format"); + expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.generateRequests).toEqual([]); + }), + ), + ); + + it.effect("retries the exact model bootstrap race and then succeeds", () => + withOpenCode2TextGeneration(DEFAULT_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.generateErrors.push( + new Error("Model unavailable: opencode/big-pickle"), + new Error("Model unavailable: opencode/big-pickle"), + ); + const fiber = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_INPUT) + .pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(1)); + const result = yield* Fiber.join(fiber); + + expect(result.subject).toBe("Add OpenCode 2 generation"); + expect(runtimeMock.state.generateRequests).toHaveLength(3); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("uses a temporary session for an agent selection and removes it after generation", () => + withOpenCode2TextGeneration(DEFAULT_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.generateResponse = { + data: { + data: { + text: '{"title":"Fix the model picker icon"}', + }, + }, + }; + + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "The provider icon is missing.", + attachments: [IMAGE_ATTACHMENT], + modelSelection: { + ...DEFAULT_MODEL_SELECTION, + options: [{ id: "agent", value: "build" }], + }, + }); + + expect(result).toEqual({ title: "Fix the model picker icon" }); + expect(runtimeMock.state.generateRequests).toEqual([]); + expect(runtimeMock.state.sessionCreateRequests).toEqual([ + { + model: { providerID: "opencode", id: "big-pickle" }, + location: { directory: process.cwd() }, + agent: "build", + }, + ]); + expect(runtimeMock.state.sessionGenerateRequests).toHaveLength(1); + expect(runtimeMock.state.sessionGenerateRequests[0]).toMatchObject({ + sessionID: "temporary-session", + }); + expect(runtimeMock.state.sessionGenerateRequests[0]?.prompt).toContain( + "Attachment metadata:", + ); + expect(runtimeMock.state.sessionGenerateRequests[0]?.prompt).toContain("picker.png"); + expect(runtimeMock.state.sessionRemoveRequests).toEqual([ + { sessionID: "temporary-session" }, + ]); + }), + ), + ); + + it.effect("removes a temporary attachment session when generation fails", () => + withOpenCode2TextGeneration(DEFAULT_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const sdkCause = new Error("generation unavailable"); + runtimeMock.state.sessionGenerateError = sdkCause; + + const error = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "The provider icon is missing.", + attachments: [IMAGE_ATTACHMENT], + modelSelection: { + ...DEFAULT_MODEL_SELECTION, + options: [{ id: "agent", value: "build" }], + }, + }) + .pipe(Effect.flip); + + expect(error.message).toContain("session.generate request failed"); + expect(error.message).not.toContain(sdkCause.message); + expect(error.cause).toMatchObject({ + _tag: "OpenCode2RuntimeError", + category: "sdk-request-failed", + operation: "session.generate", + cause: sdkCause, + }); + expect(runtimeMock.state.sessionRemoveRequests).toEqual([ + { sessionID: "temporary-session" }, + ]); + }), + ), + ); + + it.effect("rejects blank generation output as a typed error", () => + withOpenCode2TextGeneration(DEFAULT_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.generateResponse = { + data: { + data: { + text: " ", + }, + }, + }; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_INPUT) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.message).toContain("OpenCode 2 returned empty output"); + }), + ), + ); +}); diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts new file mode 100644 index 00000000000..5f8f104c945 --- /dev/null +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts @@ -0,0 +1,511 @@ +import type { ModelRef, OpencodeClient } from "@opencode-ai/sdk-next/v2"; +import { + TextGenerationError, + type ModelSelection, + type OpenCode2Settings, +} from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; + +import { parseOpenCodeModelSlug } from "../provider/opencodeRuntime.ts"; +import * as OpenCode2Runtime from "../provider/opencode2Runtime.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +const OPENCODE2_TEXT_GENERATION_IDLE_TTL = "30 seconds"; +const OPENCODE2_TEXT_GENERATION_MODEL_RETRY_DELAY = "500 millis"; +const OPENCODE2_TEXT_GENERATION_MODEL_MAX_ATTEMPTS = 5; + +const OpenCode2TextGenerationOperation = Schema.Literals([ + "generateCommitMessage", + "generatePrContent", + "generateBranchName", + "generateThreadTitle", +]); +type OpenCode2TextGenerationOperation = typeof OpenCode2TextGenerationOperation.Type; + +const OpenCode2GenerateResponse = Schema.Struct({ + data: Schema.Struct({ + data: Schema.Struct({ + text: Schema.String, + }), + }), +}); + +const OpenCode2SessionCreateResponse = Schema.Struct({ + data: Schema.Struct({ + data: Schema.Struct({ + id: Schema.String, + }), + }), +}); +const decodeOpenCode2GenerateResponse = Schema.decodeUnknownEffect(OpenCode2GenerateResponse); +const decodeOpenCode2SessionCreateResponse = Schema.decodeUnknownEffect( + OpenCode2SessionCreateResponse, +); + +interface SharedOpenCode2TextGenerationServerState { + server: OpenCode2Runtime.OpenCode2ServerProcess | null; + serverScope: Scope.Closeable | null; + activeRequests: number; + idleCloseFiber: Fiber.Fiber | null; +} + +function modelRefFor( + operation: OpenCode2TextGenerationOperation, + modelSelection: ModelSelection, +): Effect.Effect { + const parsed = parseOpenCodeModelSlug(modelSelection.model); + if (parsed === null) { + return Effect.fail( + new TextGenerationError({ + operation, + detail: "OpenCode 2 model selection must use the 'provider/model' format.", + }), + ); + } + const variant = OpenCode2Runtime.normalizeOpenCode2Variant( + getModelSelectionStringOptionValue(modelSelection, "variant"), + ); + return Effect.succeed({ + id: parsed.modelID, + providerID: parsed.providerID, + ...(variant === undefined ? {} : { variant }), + }); +} + +function isOpenCode2ModelStartupError(error: TextGenerationError): boolean { + return ( + OpenCode2Runtime.isOpenCode2RuntimeError(error.cause) && + error.cause.category === "model-unavailable" + ); +} + +export const makeOpenCode2TextGeneration = Effect.fn("makeOpenCode2TextGeneration")(function* ( + settings: OpenCode2Settings, + environment?: NodeJS.ProcessEnv, +) { + const runtime = yield* OpenCode2Runtime.OpenCode2Runtime; + const resolvedEnvironment = environment ?? process.env; + const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const sharedServerMutex = yield* Semaphore.make(1); + const sharedServerState: SharedOpenCode2TextGenerationServerState = { + server: null, + serverScope: null, + activeRequests: 0, + idleCloseFiber: null, + }; + + const closeSharedServer = Effect.fn("OpenCode2TextGeneration.closeSharedServer")(function* () { + const scope = sharedServerState.serverScope; + sharedServerState.server = null; + sharedServerState.serverScope = null; + sharedServerState.activeRequests = 0; + if (scope !== null) { + yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); + } + }); + + const cancelIdleCloseFiber = Effect.fn("OpenCode2TextGeneration.cancelIdleCloseFiber")( + function* () { + const idleCloseFiber = sharedServerState.idleCloseFiber; + sharedServerState.idleCloseFiber = null; + if (idleCloseFiber !== null) { + yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); + } + }, + ); + + const scheduleIdleClose = Effect.fn("OpenCode2TextGeneration.scheduleIdleClose")(function* ( + server: OpenCode2Runtime.OpenCode2ServerProcess, + ) { + yield* cancelIdleCloseFiber(); + sharedServerState.idleCloseFiber = yield* Effect.sleep(OPENCODE2_TEXT_GENERATION_IDLE_TTL).pipe( + Effect.andThen( + sharedServerMutex.withPermit( + Effect.gen(function* () { + if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { + return; + } + sharedServerState.idleCloseFiber = null; + yield* closeSharedServer(); + }), + ), + ), + Effect.forkIn(idleFiberScope), + ); + }); + + const acquireSharedServer = ( + operation: OpenCode2TextGenerationOperation, + ): Effect.Effect => + sharedServerMutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleCloseFiber(); + const existingServer = sharedServerState.server; + if (existingServer !== null) { + const isRunning = yield* existingServer.isRunning; + if (isRunning) { + sharedServerState.activeRequests += 1; + return existingServer; + } + yield* closeSharedServer(); + } + + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const serverScope = yield* Scope.make(); + const startedExit = yield* Effect.exit( + restore( + runtime + .startOpenCode2ServerProcess({ + binaryPath: settings.binaryPath, + environment: resolvedEnvironment, + }) + .pipe( + Effect.provideService(Scope.Scope, serverScope), + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "OpenCode 2 server startup failed.", + cause, + }), + ), + ), + ), + ); + if (Exit.isFailure(startedExit)) { + yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); + return yield* Effect.failCause(startedExit.cause); + } + + sharedServerState.server = startedExit.value; + sharedServerState.serverScope = serverScope; + sharedServerState.activeRequests = 1; + return startedExit.value; + }), + ); + }), + ); + + const releaseSharedServer = (server: OpenCode2Runtime.OpenCode2ServerProcess) => + sharedServerMutex.withPermit( + Effect.gen(function* () { + if (sharedServerState.server !== server) return; + sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); + if (sharedServerState.activeRequests === 0) { + yield* scheduleIdleClose(server); + } + }), + ); + + yield* Effect.addFinalizer(() => + sharedServerMutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleCloseFiber(); + yield* closeSharedServer(); + }), + ), + ); + + const sdkCall = ( + operation: OpenCode2TextGenerationOperation, + method: OpenCode2Runtime.OpenCode2RuntimeOperation, + call: () => Promise, + ): Effect.Effect => + OpenCode2Runtime.runOpenCode2Sdk(method, call).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: `OpenCode 2 ${method} request failed.`, + cause, + }), + ), + ); + + const retryModelStartup = ( + effect: () => Effect.Effect, + attempt = 1, + ): Effect.Effect => + effect().pipe( + Effect.catch((error) => + attempt < OPENCODE2_TEXT_GENERATION_MODEL_MAX_ATTEMPTS && + isOpenCode2ModelStartupError(error) + ? Effect.sleep(OPENCODE2_TEXT_GENERATION_MODEL_RETRY_DELAY).pipe( + Effect.andThen(retryModelStartup(effect, attempt + 1)), + ) + : Effect.fail(error), + ), + ); + + const decodeGenerateText = Effect.fn("OpenCode2TextGeneration.decodeGenerateText")(function* ( + operation: OpenCode2TextGenerationOperation, + response: unknown, + ) { + const decoded = yield* decodeOpenCode2GenerateResponse(response).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "OpenCode 2 returned an invalid generation response.", + cause, + }), + ), + ); + const text = decoded.data.data.text.trim(); + if (text.length === 0) { + return yield* new TextGenerationError({ + operation, + detail: "OpenCode 2 returned empty output.", + }); + } + return text; + }); + + const generateWithSessionAgent = Effect.fn("OpenCode2TextGeneration.generateWithSessionAgent")( + function* (input: { + readonly client: OpencodeClient; + readonly operation: OpenCode2TextGenerationOperation; + readonly cwd: string; + readonly prompt: string; + readonly model: ModelRef; + readonly agent: string; + }) { + const createResponse = yield* sdkCall(input.operation, "session.create", () => + input.client.v2.session.create({ + model: input.model, + location: { directory: input.cwd }, + agent: input.agent, + }), + ); + const created = yield* decodeOpenCode2SessionCreateResponse(createResponse).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 returned an invalid session creation response.", + cause, + }), + ), + ); + const sessionID = created.data.data.id; + + return yield* Effect.gen(function* () { + const response = yield* sdkCall(input.operation, "session.generate", () => + input.client.v2.session.generate({ + sessionID, + prompt: input.prompt, + }), + ); + return yield* decodeGenerateText(input.operation, response); + }).pipe( + Effect.ensuring( + sdkCall(input.operation, "session.remove", () => + input.client.v2.session.remove({ sessionID }), + ).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to remove temporary OpenCode 2 text generation session.", { + sessionID, + cause, + }), + ), + ), + ), + ); + }, + ); + + const runOpenCode2Json = Effect.fn("OpenCode2TextGeneration.runOpenCode2Json")(function* < + S extends Schema.Top, + >(input: { + readonly operation: OpenCode2TextGenerationOperation; + readonly cwd: string; + readonly prompt: string; + readonly outputSchemaJson: S; + readonly modelSelection: ModelSelection; + }) { + const model = yield* modelRefFor(input.operation, input.modelSelection); + const agent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + + const runAgainstServer = Effect.fn("OpenCode2TextGeneration.runAgainstServer")(function* ( + server: OpenCode2Runtime.OpenCode2ServerCredentials, + ) { + const client = runtime.createOpenCode2SdkClient({ + baseUrl: server.url, + directory: input.cwd, + serverPassword: server.password, + }); + return yield* retryModelStartup(() => + agent !== undefined + ? generateWithSessionAgent({ + client, + operation: input.operation, + cwd: input.cwd, + prompt: input.prompt, + model, + agent, + }) + : sdkCall(input.operation, "generate.text", () => + client.v2.generate.text({ + location: { directory: input.cwd }, + prompt: input.prompt, + model, + }), + ).pipe(Effect.flatMap((response) => decodeGenerateText(input.operation, response))), + ); + }); + + const rawOutput = + settings.serverUrl.length > 0 + ? yield* runtime + .connectToOpenCode2Server({ + binaryPath: settings.binaryPath, + serverUrl: settings.serverUrl, + serverPassword: settings.serverPassword, + environment: resolvedEnvironment, + }) + .pipe( + Effect.provideService(Scope.Scope, idleFiberScope), + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 server connection failed.", + cause, + }), + ), + Effect.flatMap(runAgainstServer), + ) + : yield* Effect.acquireUseRelease( + acquireSharedServer(input.operation), + runAgainstServer, + releaseSharedServer, + ); + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawOutput)).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 returned invalid structured output.", + cause, + }), + ), + ); + }); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("OpenCode2TextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + const generated = yield* runOpenCode2Json({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("OpenCode2TextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + changeRequestTemplate: input.changeRequestTemplate, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + }); + const generated = yield* runOpenCode2Json({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("OpenCode2TextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCode2Json({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("OpenCode2TextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + previousTitle: input.previousTitle, + }); + const generated = yield* runOpenCode2Json({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f..ff58a1e8e95 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "opencode" + | "opencode2"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f108a56dfda..73e0fdd7fe7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -30,6 +30,7 @@ import { deriveThreadActivityRun, deriveLatestThreadRun, deriveThreadRuntime, + threadRuntimeHasInterruptibleProviderNativeBackgroundWork, } from "@t3tools/client-runtime/state/thread-execution"; import { resolveThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows"; import { resolveThreadLastVisitedAt } from "./Sidebar.logic"; @@ -2232,6 +2233,8 @@ function ChatViewContent(props: ChatViewProps) { const selectedProvider: ProviderDriverKind = modelPickerLockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeRuntime); + const hasInterruptibleProviderNativeBackgroundWork = + threadRuntimeHasInterruptibleProviderNativeBackgroundWork(activeRuntime); const pendingRequests = useMemo( () => serverProjection === null @@ -6384,6 +6387,9 @@ function ChatViewContent(props: ChatViewProps) { forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState} projectSelectionRequired={isLocalDraftThread && activeProject === null} phase={phase} + hasInterruptibleProviderNativeBackgroundWork={ + hasInterruptibleProviderNativeBackgroundWork + } isConnecting={isConnecting} isSendBusy={isSendBusy} isPreparingWorktree={isPreparingWorktree} diff --git a/apps/web/src/components/Icons.test.tsx b/apps/web/src/components/Icons.test.tsx new file mode 100644 index 00000000000..5b86688a7f0 --- /dev/null +++ b/apps/web/src/components/Icons.test.tsx @@ -0,0 +1,35 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { OpenCode2Icon, OpenCodeIcon } from "./Icons"; + +const OPEN_CODE_2_DIGIT_PATH = + "M8 8H24V12H8V8ZM20 12H24V20H20V12ZM8 16H20V20H8V16ZM8 20H12V28H8V20ZM8 28H24V32H8V28Z"; + +function markPaths(markup: string): ReadonlyArray { + return [...markup.matchAll(/ d="([^"]+)"/g)].map((match) => match[1]!); +} + +describe("OpenCode icons", () => { + it("keeps the shared frame while preview uses the official dev blue treatment", () => { + const openCode = renderToStaticMarkup(); + const openCode2 = renderToStaticMarkup(); + + expect(openCode).toContain('viewBox="0 0 32 40"'); + expect(openCode2).toContain('viewBox="0 0 32 40"'); + expect(openCode2).not.toContain("stroke="); + expect(markPaths(openCode2).slice(-2)).toEqual(markPaths(openCode).slice(-2)); + expect(openCode2).toContain('fill="#2E6CE9"'); + expect(openCode2).toContain('fill="#82C4FF"'); + expect(openCode2).toContain('fill="#0A2055"'); + }); + + it("no longer draws a version number into the OpenCode 2 glyph", () => { + expect(renderToStaticMarkup()).not.toContain(OPEN_CODE_2_DIGIT_PATH); + }); + + it("keeps the two generations identifiable in the DOM", () => { + expect(renderToStaticMarkup()).toContain('data-provider-icon="opencode"'); + expect(renderToStaticMarkup()).toContain('data-provider-icon="opencode2"'); + }); +}); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c51958..7370442bfa3 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -647,19 +647,54 @@ export const AntigravityIcon: Icon = (props) => ( ); +const OpenCodeFramePaths = () => ( + <> + + + +); + +const OpenCodeMarkPaths = () => ( + <> + + + + +); + +// The OpenCode 2 preview borrows the blue inner field, highlight, and shadow +// from OpenCode's official dev desktop icon while retaining a contrasting +// neutral outer frame in both application themes. +const OpenCode2MarkPaths = () => ( + <> + + + + + +); + export const OpenCodeIcon: Icon = (props) => ( - - - - - - - - - - - - + + + +); + +export const OpenCode2Icon: Icon = (props) => ( + + ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9b1bff8eb7b..4061fe16064 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -423,6 +423,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isComplete: boolean; } | null; isRunning: boolean; + canInterrupt: boolean; + showSecondaryStop: boolean; showPlanFollowUpPrompt: boolean; promptHasText: boolean; isSendBusy: boolean; @@ -455,6 +457,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( compact={props.compact} pendingAction={props.pendingAction} isRunning={props.isRunning} + canInterrupt={props.canInterrupt} + showSecondaryStop={props.showSecondaryStop} showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} promptHasText={props.promptHasText} isSendBusy={props.isSendBusy} @@ -536,6 +540,7 @@ export interface ChatComposerProps { // Session phase phase: SessionPhase; + hasInterruptibleProviderNativeBackgroundWork: boolean; isConnecting: boolean; isSendBusy: boolean; isPreparingWorktree: boolean; @@ -652,6 +657,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) forceExpandedOnMobile, projectSelectionRequired, phase, + hasInterruptibleProviderNativeBackgroundWork, isConnecting, isSendBusy, isPreparingWorktree, @@ -3160,7 +3166,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {/* Bottom toolbar */} - {isComposerCollapsedMobile ? null : activePendingApproval ? ( + {!isComposerCollapsedMobile && activePendingApproval && (
- ) : ( + )} + {!isComposerCollapsedMobile && !activePendingApproval && (
0} isSendBusy={isSendBusy} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts index 655e83bc062..61e4e961841 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts @@ -135,4 +135,138 @@ describe("active-turn primary action", () => { expect(markup).toContain('aria-label="Send message to steer active turn"'); expect(markup).not.toContain('aria-label="Stop generation"'); }); + + it("shows stop for native background work without active-turn copy", () => { + const emptyMarkup = renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + ...activeTurnProps, + isRunning: false, + canInterrupt: true, + hasSendableContent: false, + }), + ); + const contentMarkup = renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + ...activeTurnProps, + isRunning: false, + canInterrupt: true, + showSecondaryStop: true, + hasSendableContent: true, + }), + ); + + expect(contentMarkup).toContain('aria-label="Stop generation"'); + expect(emptyMarkup).toContain('aria-label="Stop generation"'); + expect(contentMarkup).toContain('aria-label="Send message"'); + expect(contentMarkup).not.toContain("steer active turn"); + }); + + it("keeps Implement primary with a secondary Stop for background work", () => { + const markup = renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + ...activeTurnProps, + isRunning: false, + canInterrupt: true, + showSecondaryStop: true, + showPlanFollowUpPrompt: true, + hasSendableContent: false, + }), + ); + + expect(markup).toContain('data-chat-composer-implement-actions="true"'); + expect(markup).toContain("Implement"); + expect(markup).toContain('aria-label="Stop generation"'); + }); + + it("keeps Refine primary with a secondary Stop for background work", () => { + const markup = renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + ...activeTurnProps, + isRunning: false, + canInterrupt: true, + showSecondaryStop: true, + showPlanFollowUpPrompt: true, + promptHasText: true, + hasSendableContent: true, + }), + ); + + expect(markup).toContain("Refine"); + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Send message"'); + }); + + it("keeps the pending question action with a secondary Stop for background work", () => { + const markup = renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + ...activeTurnProps, + pendingAction: { + questionIndex: 0, + isLastQuestion: true, + canAdvance: true, + isResponding: false, + isComplete: true, + }, + isRunning: false, + canInterrupt: true, + showSecondaryStop: true, + hasSendableContent: false, + }), + ); + + expect(markup).toContain("Submit answer"); + expect(markup).toContain('aria-label="Stop generation"'); + }); + + it("does not add background Stop to an active pending question", () => { + const markup = renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + ...activeTurnProps, + pendingAction: { + questionIndex: 0, + isLastQuestion: true, + canAdvance: true, + isResponding: false, + isComplete: true, + }, + isRunning: true, + showSecondaryStop: true, + hasSendableContent: false, + }), + ); + + expect(markup).toContain("Submit answer"); + expect(markup).not.toContain('aria-label="Stop generation"'); + }); + + it("does not add background Stop beside active-turn steering", () => { + const markup = renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + ...activeTurnProps, + isRunning: true, + showSecondaryStop: true, + hasSendableContent: true, + }), + ); + + expect(markup).toContain('aria-label="Send message to steer active turn"'); + expect(markup).not.toContain('aria-label="Stop generation"'); + }); + + it("keeps Stop reachable when a running turn still has a plan follow-up", () => { + const markup = renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + ...activeTurnProps, + isRunning: true, + canInterrupt: true, + showPlanFollowUpPrompt: true, + hasSendableContent: false, + }), + ); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain("Implement"); + expect(markup).not.toContain("Refine"); + expect(markup).not.toContain('data-chat-composer-implement-actions="true"'); + }); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 188da99f362..8e69bc676dd 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -20,6 +20,8 @@ interface ComposerPrimaryActionsProps { compact: boolean; pendingAction: PendingActionState | null; isRunning: boolean; + canInterrupt?: boolean; + showSecondaryStop?: boolean; showPlanFollowUpPrompt: boolean; promptHasText: boolean; isSendBusy: boolean; @@ -59,6 +61,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ compact, pendingAction, isRunning, + canInterrupt = isRunning, + showSecondaryStop = false, showPlanFollowUpPrompt, promptHasText, isSendBusy, @@ -79,8 +83,24 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ environmentIdentificationMode === "artwork", ); + const secondaryStopButton = + !isRunning && showSecondaryStop ? ( + + ) : null; + if (pendingAction) { - return ( + const pendingActions = (
{pendingAction.questionIndex > 0 ? ( compact ? ( @@ -128,27 +148,19 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
); - } - - if (isRunning && !hasSendableContent) { - return ( - + return secondaryStopButton === null ? ( + pendingActions + ) : ( +
+ {secondaryStopButton} + {pendingActions} +
); } - if (showPlanFollowUpPrompt) { + if (showPlanFollowUpPrompt && !isRunning) { if (promptHasText) { - return ( + const refineButton = (
); + return secondaryStopButton === null ? ( + implementActions + ) : ( +
+ {secondaryStopButton} + {implementActions} +
+ ); + } + + if (canInterrupt && !hasSendableContent) { + return ( + + ); } const sendButton = ( @@ -246,7 +290,16 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); - if (!isRunning) return sendButton; + if (!isRunning) { + return secondaryStopButton === null ? ( + sendButton + ) : ( +
+ {secondaryStopButton} + {sendButton} +
+ ); + } return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 6e14d304d03..09288252cc0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1054,6 +1054,57 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("Explain test isolation"); }); + it("renders a failed subagent as a prominent danger card", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-v2-item-type="subagent"'); + expect(markup).toContain('data-tone="danger"'); + expect(markup).toContain("HTTP 429: Rate limit exceeded"); + expect(markup).toContain("text-destructive"); + }); + it("keeps live progress when a running subagent streams a partial result", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index a74a4ebf8c2..afdff4860b1 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -78,7 +78,7 @@ export const ModelListRow = memo(function ModelListRow(props: { {props.showProvider && (
- {ProviderIcon ? : null} + {ProviderIcon ? : null} {providerLabel} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index c58716d843c..ae11764ba92 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -17,6 +17,7 @@ import { } from "./modelPickerKeys"; import { isModelPickerNewModel } from "./modelPickerModelHighlights"; import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; +import { isModelPickerItemInSelectedScope } from "./modelPickerScope"; import { Combobox, ComboboxEmpty, @@ -264,7 +265,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { } return [...available, ...disabled]; }, [instanceEntries, isLocked, matchesLockedProvider]); - const showSidebar = !isSearching && sidebarInstanceEntries.length > 0; + const showSidebar = sidebarInstanceEntries.length > 0; const instanceOrder = useMemo( () => instanceEntries.map((entry) => entry.instanceId), [instanceEntries], @@ -274,6 +275,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const filteredModels = useMemo(() => { let result = flatModels; + if (props.lockedProvider !== null) { + result = result.filter((model) => matchesLockedProvider(model)); + } + result = result.filter((model) => + isModelPickerItemInSelectedScope(model, selectedInstanceId, favoritesSet), + ); + // Apply tokenized fuzzy search across the combined provider/model search fields. if (searchQuery.trim()) { const rankedMatches = result @@ -310,30 +318,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { } => rankedModel.score !== null, ); - // When searching, we only respect locked provider (by driver kind), - // ignoring sidebar selection so account-scoped searches can find a - // model before the user chooses a specific instance rail item. - if (props.lockedProvider !== null) { - const lockedProviderMatches: Array<(typeof rankedMatches)[number]> = []; - for (const rankedModel of rankedMatches) { - if (matchesLockedProvider(rankedModel.model)) { - lockedProviderMatches.push(rankedModel); - } - } - return lockedProviderMatches - .toSorted((a, b) => { - const scoreDelta = a.score - b.score; - if (scoreDelta !== 0) { - return scoreDelta; - } - if (a.isFavorite !== b.isFavorite) { - return a.isFavorite ? -1 : 1; - } - return a.tieBreaker.localeCompare(b.tieBreaker); - }) - .map((rankedModel) => rankedModel.model); - } - return rankedMatches .toSorted((a, b) => { const scoreDelta = a.score - b.score; @@ -348,19 +332,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { .map((rankedModel) => rankedModel.model); } - if (props.lockedProvider !== null) { - result = result.filter((m) => matchesLockedProvider(m)); - if (selectedInstanceId === "favorites") { - result = result.filter((m) => favoritesSet.has(providerModelKey(m.instanceId, m.slug))); - } else { - result = result.filter((m) => m.instanceId === selectedInstanceId); - } - } else if (selectedInstanceId === "favorites") { - result = result.filter((m) => favoritesSet.has(providerModelKey(m.instanceId, m.slug))); - } else { - result = result.filter((m) => m.instanceId === selectedInstanceId); - } - return sortProviderModelItems(result, { favoriteModelKeys: favoritesSet, groupFavorites: selectedInstanceId !== "favorites", diff --git a/apps/web/src/components/chat/ModelPickerSidebar.test.tsx b/apps/web/src/components/chat/ModelPickerSidebar.test.tsx new file mode 100644 index 00000000000..3d6d4161f96 --- /dev/null +++ b/apps/web/src/components/chat/ModelPickerSidebar.test.tsx @@ -0,0 +1,129 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ModelPickerSidebar } from "./ModelPickerSidebar"; +import { deriveProviderInstanceEntries } from "../../providerInstances"; + +function snapshot(input: { + driver: ProviderDriverKind; + instanceId: string; + installed?: boolean; + status?: ServerProvider["status"]; +}): ServerProvider { + return { + instanceId: ProviderInstanceId.make(input.instanceId), + driver: input.driver, + enabled: true, + installed: input.installed ?? true, + version: null, + status: input.status ?? "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; +} + +/** The shape the server publishes while a driver's very first probe runs. */ +const pendingOpenCode2 = snapshot({ + driver: ProviderDriverKind.make("opencode2"), + instanceId: "opencode2", + installed: false, + status: "warning", +}); + +function render(providers: ReadonlyArray): string { + return renderToStaticMarkup( + {}} + instanceEntries={deriveProviderInstanceEntries(providers)} + />, + ); +} + +function railButton(markup: string, instanceId: string): string { + const markerIndex = markup.indexOf(`data-model-picker-provider="${instanceId}"`); + if (markerIndex === -1) return ""; + const markerTagStart = markup.lastIndexOf("<", markerIndex); + const markerTagEnd = markup.indexOf(">", markerIndex); + const markerTag = markup.slice(markerTagStart, markerTagEnd + 1); + if (markerTag.startsWith("", buttonStart); + return buttonStart === -1 || buttonEnd === -1 ? "" : markup.slice(buttonStart, buttonEnd + 1); +} + +describe("ModelPickerSidebar first open", () => { + it("keeps a pending OpenCode 2 rail entry at full opacity instead of dimming it", () => { + const markup = render([pendingOpenCode2]); + const button = railButton(markup, "opencode2"); + + expect(markup).toContain('data-provider-icon="opencode2"'); + expect(button).not.toContain("opacity-50"); + }); + + it("still refuses selection while that probe is pending, so the scope cannot go empty", () => { + const button = railButton(render([pendingOpenCode2]), "opencode2"); + + expect(button).toContain("disabled="); + expect(button).toContain("cursor-not-allowed"); + expect(button).toContain("Checking availability"); + }); + + it("names the provider and pending state in the accessible label", () => { + const button = railButton(render([pendingOpenCode2]), "opencode2"); + + expect(button).toContain("OpenCode 2"); + expect(button).toContain("Checking availability"); + }); + + it("gives another driver with the same snapshot shape the normal unavailable treatment", () => { + const button = railButton( + render([ + snapshot({ + driver: ProviderDriverKind.make("opencode"), + instanceId: "opencode", + installed: false, + status: "warning", + }), + ]), + "opencode", + ); + + expect(button).toContain("opacity-50"); + expect(button).toContain("disabled="); + expect(button).not.toContain("Checking availability"); + }); + + it("still dims a settled OpenCode 2 failure", () => { + const button = railButton( + render([ + snapshot({ + driver: ProviderDriverKind.make("opencode2"), + instanceId: "opencode2", + status: "error", + }), + ]), + "opencode2", + ); + + expect(button).toContain("opacity-50"); + expect(button).toContain("disabled="); + }); + + it("keeps both OpenCode generations as separate rail entries", () => { + const markup = render([ + snapshot({ driver: ProviderDriverKind.make("opencode"), instanceId: "opencode" }), + snapshot({ driver: ProviderDriverKind.make("opencode2"), instanceId: "opencode2" }), + ]); + + expect(markup).toContain('data-model-picker-provider="opencode"'); + expect(markup).toContain('data-model-picker-provider="opencode2"'); + expect(markup).toContain('data-provider-icon="opencode"'); + expect(markup).toContain('data-provider-icon="opencode2"'); + expect(markup).not.toContain("data-provider-kind-badge"); + }); +}); diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 24ec66cd614..1d2468c969a 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -4,21 +4,27 @@ import { SparklesIcon, StarIcon } from "lucide-react"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances"; +import { + isProviderInstanceInitialProbePending, + isProviderInstancePickerReady, + type ProviderInstanceEntry, +} from "../../providerInstances"; /** - * Build the hover tooltip for an instance button. Mirrors the old - * kind-based copy but uses the entry's configured `displayName` so custom - * instances get their user-authored name (e.g. "Codex Personal — Unavailable."). + * Build the hover tooltip for an instance button. `label` is the caller's + * resolved display name — the entry's configured `displayName`, so custom + * instances get their user-authored name. */ -function describeUnavailableInstance(entry: ProviderInstanceEntry): string { - const label = entry.displayName; +function describeUnavailableInstance(entry: ProviderInstanceEntry, label: string): string { if (!entry.enabled || entry.status === "disabled") { return `${label} — Disabled in settings.`; } if (entry.status === "ready" && entry.isAvailable) { return label; } + if (isProviderInstanceInitialProbePending(entry)) { + return `${label} — Checking availability…`; + } const kind = entry.status === "error" ? "Unavailable" : entry.status === "warning" ? "Limited" : "Not ready"; const msg = entry.snapshot.message?.trim(); @@ -137,28 +143,39 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { {/* Instance buttons (one per configured instance — built-in + custom) */} {props.instanceEntries.map((entry) => { + // A first probe that has not landed yet still cannot serve + // models, so the entry stays unselectable. It just stops being + // dimmed: at half opacity the OpenCode 2 rail glyph read as + // though the provider were not offered at all. + const isProbePending = isProviderInstanceInitialProbePending(entry); const isUnavailable = !isProviderInstancePickerReady(entry); const isContextDisabled = props.disabledInstanceIds?.has(entry.instanceId) ?? false; const isDisabled = isUnavailable || isContextDisabled; const isSelected = props.selectedInstanceId === entry.instanceId; const isHovered = hoveredInstanceId === entry.instanceId; - const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; const showInstanceBadge = Boolean(entry.accentColor) || (duplicateDriverCounts.get(entry.driverKind) ?? 0) > 1; - const tooltip = isUnavailable - ? describeUnavailableInstance(entry) - : isContextDisabled - ? (props.getDisabledInstanceTooltip?.(entry) ?? entry.displayName) - : showNewBadge - ? `${entry.displayName} — New` - : entry.displayName; + const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; + const displayName = entry.displayName; + let tooltip = showNewBadge ? `${displayName} — New` : displayName; + if (isContextDisabled) { + tooltip = props.getDisabledInstanceTooltip?.(entry) ?? displayName; + } + if (isUnavailable) { + tooltip = describeUnavailableInstance(entry, displayName); + } + let ariaLabel = showNewBadge ? `${displayName}, new` : displayName; + if (isDisabled) { + ariaLabel = tooltip; + } const button = (