From 311da51db22ffc7e759634b785b8327b5d075fd8 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 15 Oct 2026 02:15:07 -0700 Subject: [PATCH] feat: integrate `@clo/lib/progress` Clover wants long-running scripts to show their progress in the chat instead of leaving it buried in command output. Capture existing @clo/lib/progress copies without requiring script changes or a library upgrade. Preload a synchronous Node module hook through thread-scoped command environments. Observe each loaded progress module and forward its stable event protocol to a bounded loopback collector. Preserve existing Node options, command output, exit behavior, worker isolation, and parent progress pipes. Codex, Claude, Pi, Muse, ACP commands, and T3 terminals use this path; the current Cursor and OpenCode APIs do not expose the necessary command environment. Copy the progress implementation and browser renderer from Clover's unpublished library until that release is available. Web and desktop render nested task trees above the composer, and mobile renders task summaries. Fresh subscriptions receive full trees after reconnects or switching chats. Disconnections retain the last values without claiming success. Keep progress out of durable chat state. Verify real v3/v4 scripts, preload ordering, handled and unhandled errors, concurrent producers, reconnects, bounded streams, provider environments, and client batching. Exercise the real client with a v4 script in Iris, light, dark, and a narrow viewport, including chat switching and reloads. Assisted-by: gpt-6.1-sol --- .../src/features/threads/ScriptProgress.tsx | 65 + .../features/threads/ThreadDetailScreen.tsx | 5 + apps/mobile/src/state/scriptProgress.ts | 6 + apps/server/package.json | 1 + apps/server/src/auth/RpcAuthorization.test.ts | 5 + apps/server/src/auth/RpcAuthorization.ts | 1 + ...OrchestratorMcpToolkit.integration.test.ts | 5 +- .../src/observability/RpcInstrumentation.ts | 1 + .../AcpRegistryOrchestratorV2.live.test.ts | 3 +- .../Adapters/AcpAdapterV2.test.ts | 2 + .../Adapters/AcpRegistryAdapterV2.test.ts | 2 + .../Adapters/AntigravityAdapterV2.test.ts | 2 + .../Adapters/ClaudeAdapterV2.test.ts | 880 ++++++- .../Adapters/ClaudeAdapterV2.ts | 9 +- .../Adapters/CodexAdapterV2.test.ts | 568 ++++- .../Adapters/CodexAdapterV2.ts | 59 +- .../Adapters/GrokAdapterV2.test.ts | 2 + .../Adapters/OpenCode2AdapterV2.test.ts | 9 +- .../Adapters/codexScriptProgress.test.ts | 65 + .../Adapters/codexScriptProgress.ts | 36 + ...laudeAutomaticDelivery.integration.test.ts | 8 +- .../CursorOrchestratorV2.live.test.ts | 3 +- .../DelegatedCompletionDelivery.test.ts | 3 +- .../GrokOrchestratorV2.live.test.ts | 3 +- .../OpenCode2OrchestratorV2.live.test.ts | 3 +- ...ProjectSettingsUpgrade.integration.test.ts | 3 +- .../ProviderSessionManager.test.ts | 8 +- .../src/orchestration-v2/runtimeLayer.test.ts | 11 +- .../testkit/ProviderReplayHarness.ts | 14 +- .../src/progress/ScriptProgress.test.ts | 537 +++++ apps/server/src/progress/ScriptProgress.ts | 374 +++ .../Drivers/AntigravityDriver.test.ts | 3 +- .../src/provider/Drivers/AntigravityDriver.ts | 7 +- .../src/provider/Drivers/CodexDriver.test.ts | 3 +- .../provider/ProviderInstanceRegistry.test.ts | 5 +- .../src/provider/ProviderRegistry.test.ts | 17 +- .../src/provider/acp/AntigravityAcpSupport.ts | 1 + apps/server/src/server.ts | 2 + .../src/terminal/Manager.progress.test.ts | 114 + apps/server/src/terminal/Manager.test.ts | 3 +- apps/server/src/terminal/Manager.ts | 16 +- apps/server/src/ws.ts | 3 + apps/web/package.json | 1 + apps/web/src/components/ChatView.tsx | 7 + .../components/chat/ScriptProgress.test.tsx | 191 ++ .../src/components/chat/ScriptProgress.tsx | 162 ++ apps/web/src/state/scriptProgress.ts | 6 + apps/web/tsconfig.json | 2 +- docs/user/activity-log.md | 14 + packages/client-runtime/package.json | 4 + packages/client-runtime/src/rpc/client.ts | 1 + .../src/state/scriptProgress.test.ts | 101 + .../src/state/scriptProgress.ts | 54 + packages/clo-lib/Events.ts | 107 + packages/clo-lib/assert.ts | 50 + packages/clo-lib/async.ts | 297 +++ packages/clo-lib/bytes.ts | 12 + packages/clo-lib/exception.ts | 51 + packages/clo-lib/license.txt | 13 + packages/clo-lib/log.ts | 2070 +++++++++++++++++ packages/clo-lib/log/stack.ts | 432 ++++ packages/clo-lib/math.ts | 8 + packages/clo-lib/node.ts | 289 +++ packages/clo-lib/package.json | 18 + packages/clo-lib/progress-browser.ts | 373 +++ packages/clo-lib/progress-format.ts | 23 + packages/clo-lib/progress.test.ts | 85 + packages/clo-lib/progress.ts | 1917 +++++++++++++++ packages/clo-lib/stream.ts | 484 ++++ packages/clo-lib/string.ts | 121 + packages/clo-lib/string/ansi.ts | 596 +++++ packages/clo-lib/ts.ts | 107 + packages/clo-lib/tsconfig.json | 7 + packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 14 + packages/contracts/src/scriptProgress.ts | 44 + .../src/server/adapter.ts | 7 +- packages/provider-acp/src/server/adapter.ts | 16 +- .../src/server/clientTerminals.test.ts | 47 + .../src/server/clientTerminals.ts | 9 +- packages/provider-core/package.json | 4 + .../src/server/ScriptProgress.ts | 50 + packages/provider-grok/src/server/adapter.ts | 8 +- .../provider-grok/src/server/driver.test.ts | 3 +- .../provider-muse/src/server/adapter.test.ts | 2 + packages/provider-muse/src/server/adapter.ts | 8 +- packages/provider-muse/src/server/driver.ts | 2 + .../provider-pi/src/server/adapter.test.ts | 2 + packages/provider-pi/src/server/adapter.ts | 8 +- .../provider-pi/src/server/driver.test.ts | 2 + packages/shared/package.json | 4 + packages/shared/src/progressPreload.test.ts | 559 +++++ packages/shared/src/progressPreload.ts | 169 ++ pnpm-lock.yaml | 15 + 94 files changed, 11204 insertions(+), 240 deletions(-) create mode 100644 apps/mobile/src/features/threads/ScriptProgress.tsx create mode 100644 apps/mobile/src/state/scriptProgress.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/codexScriptProgress.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/codexScriptProgress.ts create mode 100644 apps/server/src/progress/ScriptProgress.test.ts create mode 100644 apps/server/src/progress/ScriptProgress.ts create mode 100644 apps/server/src/terminal/Manager.progress.test.ts create mode 100644 apps/web/src/components/chat/ScriptProgress.test.tsx create mode 100644 apps/web/src/components/chat/ScriptProgress.tsx create mode 100644 apps/web/src/state/scriptProgress.ts create mode 100644 packages/client-runtime/src/state/scriptProgress.test.ts create mode 100644 packages/client-runtime/src/state/scriptProgress.ts create mode 100644 packages/clo-lib/Events.ts create mode 100644 packages/clo-lib/assert.ts create mode 100644 packages/clo-lib/async.ts create mode 100644 packages/clo-lib/bytes.ts create mode 100644 packages/clo-lib/exception.ts create mode 100644 packages/clo-lib/license.txt create mode 100644 packages/clo-lib/log.ts create mode 100644 packages/clo-lib/log/stack.ts create mode 100644 packages/clo-lib/math.ts create mode 100644 packages/clo-lib/node.ts create mode 100644 packages/clo-lib/package.json create mode 100644 packages/clo-lib/progress-browser.ts create mode 100644 packages/clo-lib/progress-format.ts create mode 100644 packages/clo-lib/progress.test.ts create mode 100644 packages/clo-lib/progress.ts create mode 100644 packages/clo-lib/stream.ts create mode 100644 packages/clo-lib/string.ts create mode 100644 packages/clo-lib/string/ansi.ts create mode 100644 packages/clo-lib/ts.ts create mode 100644 packages/clo-lib/tsconfig.json create mode 100644 packages/contracts/src/scriptProgress.ts create mode 100644 packages/provider-core/src/server/ScriptProgress.ts create mode 100644 packages/shared/src/progressPreload.test.ts create mode 100644 packages/shared/src/progressPreload.ts diff --git a/apps/mobile/src/features/threads/ScriptProgress.tsx b/apps/mobile/src/features/threads/ScriptProgress.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dab9c6da73c18b2c02eda19c66e7f9c493bd780e --- /dev/null +++ b/apps/mobile/src/features/threads/ScriptProgress.tsx @@ -0,0 +1,65 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { ScrollView, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { scriptProgressEnvironment } from "../../state/scriptProgress"; +import { useEnvironmentQuery } from "../../state/query"; + +export function ScriptProgress({ + environmentId, + threadId, +}: { + environmentId: EnvironmentId; + threadId: ThreadId; +}) { + const { data, isSuccess } = useEnvironmentQuery( + scriptProgressEnvironment.watch({ environmentId, input: { threadId } }), + ); + const streams = isSuccess + ? (data?.streams.filter((stream) => stream.summary.length > 0) ?? []) + : []; + if (streams.length === 0) return null; + + return ( + + + {streams.map((stream) => ( + + {stream.status === "disconnected" ? ( + {stream.label} · Disconnected + ) : null} + {stream.summary.map((node, index) => { + const fraction = + node.total > 0 ? Math.max(0, Math.min(1, node.value / node.total)) : 0; + return ( + // Summary rows are ordered by their progress root; labels may repeat. + // oxlint-disable-next-line react/no-array-index-key + + + {node.text} + {node.detail} + + {node.total > 0 ? ( + + + + ) : null} + + ); + })} + + ))} + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 1707debb95eecd952211f233bd74b361680726f5..70268cfb7902ac0344bf657424c3ab417f1d565c 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; +import { ScriptProgress } from "./ScriptProgress"; import { useNavigation } from "@react-navigation/native"; import type { WorktreeSetupCardProps } from "./worktree-setup-card"; import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; @@ -1243,6 +1244,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread list's bottom inset, so any padding above the pill/composer pushes the resting content floor up by the same amount. */} + { it("requires filesystem read permission to watch file and folder changes", () => { expect(requiredScopeForRpcMethod(WS_METHODS.projectsWatch)).toBe(AuthFilesystemReadScope); }); + it("reads script progress under orchestration read access", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.subscribeScriptProgress)).toBe( + AuthOrchestrationReadScope, + ); + }); it("declares exactly one scope for every RPC in the server group", () => { expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys())); }); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index d8c1e87c111203a37d39a69f2f78eafb9b1d1733..dd84dc17e04563adcc79f4aa4458ed1700b1e630 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -189,6 +189,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.terminalClose]: AuthTerminalOperateScope, [WS_METHODS.subscribeTerminalEvents]: AuthTerminalReadScope, [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalReadScope, + [WS_METHODS.subscribeScriptProgress]: AuthOrchestrationReadScope, [WS_METHODS.previewOpen]: AuthPreviewOperateScope, [WS_METHODS.previewNavigate]: AuthPreviewOperateScope, [WS_METHODS.previewResize]: AuthPreviewOperateScope, diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index c518fd45948144e9d73947ad9c2f65af522bca04..a57df29c5713e12129dc2dcc870be64685ed9135 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; @@ -3911,7 +3912,9 @@ describe("orchestrator MCP toolkit", () => { Layer.provideMerge(layerOrchestration), Layer.provide( CodexOrchestratorReplayHarness.makeProviderAdapterRegistryLayer(transcript).pipe( - Layer.provide(McpProviderSessions.layer), + Layer.provide( + Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), + ), ), ), Layer.provide(layerProviderRegistry), diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 34353c888fafaa8cf9dbc4085036edbde6293fc0..416ab667c7506db5f524da967bf9bdd827aa33f5 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -184,6 +184,7 @@ const RPC_AGGREGATES = { [WS_METHODS.terminalClose]: "terminal", [WS_METHODS.subscribeTerminalEvents]: "terminal", [WS_METHODS.subscribeTerminalMetadata]: "terminal", + [WS_METHODS.subscribeScriptProgress]: "orchestration", [WS_METHODS.previewOpen]: "preview", [WS_METHODS.previewNavigate]: "preview", [WS_METHODS.previewResize]: "preview", diff --git a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts index da89b3bc622782e436e1e8e02749de56bffd1175..543228a0ac544d2157c7315c9ab1faa82ab85838 100644 --- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -145,7 +146,7 @@ const layerLive = RuntimeLayer.layer.pipe( Layer.provide(layerBackgroundPolicy), Layer.provide(ProviderTurnStartServiceTestkit.layer), Layer.provide(layerPlatformTest), - Layer.provide(McpProviderSessions.layer), + Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), ); const waitForIdle = Effect.fn("AcpRegistryOrchestratorV2Live.waitForIdle")(function* ( diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 3d5540933164a5627b83e483cc87e29774620441..e584f7b0dda60068c0fb34fbc8c9af5655cc47a1 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import { extractCopilotSubagentUpdate, makeCopilotSubagentRouting, @@ -108,6 +109,7 @@ const layerTest = Layer.mergeAll( NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer, + ScriptProgress.layerUnavailable, layerHost, ); const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 171f7ae7eedc72d0eb9458716c4883008cca5763..f8d5f0a629b669fbe4031cda123b824405a8d997 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; @@ -93,6 +94,7 @@ const layerTest = Layer.mergeAll( NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer, + ScriptProgress.layerUnavailable, layerHost, layerRegistry, ServerSettings.layerTest(), diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 825d0d81508ce94f767e0707ca2bdb8aa7b3e821..8d26bc901297bc6ecec6c9e62af8cb73255f21a1 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { @@ -131,6 +132,7 @@ const layerSession = Layer.mergeAll( NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer, + ScriptProgress.layerUnavailable, TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), ); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index edaafff081e2e3f34fb1c244a7aef8bd66b57329..4ea85bed440e803646987300262b9a3a042aeb72 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; // @effect-diagnostics nodeBuiltinImport:off -- workflow fixtures use files and the fake Claude CLI uses Node streams import * as NodeEvents from "node:events"; import * as NodeFS from "node:fs"; @@ -1114,7 +1115,12 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -1143,7 +1149,7 @@ describe("ClaudeAdapterV2 approval cancellation", () => { const controller = new AbortController(); let removes = 0; const removeEventListener = controller.signal.removeEventListener.bind(controller.signal); - controller.signal.removeEventListener = (...args) => { + controller.signal.removeEventListener = (...args: Parameters) => { removes += 1; return removeEventListener(...args); }; @@ -1261,7 +1267,12 @@ describe("ClaudeAdapterV2 executable path", () => { ).pipe( Effect.provideService(HostProcess.Environment, testCase.hostEnvironment), Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -1281,7 +1292,12 @@ describe("ClaudeAdapterV2 executable path", () => { }), ).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -1505,7 +1521,12 @@ describe("ClaudeAdapterV2 resume compaction", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -1655,7 +1676,12 @@ describe("ClaudeAdapterV2 attachments", () => { ]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -1743,7 +1769,12 @@ describe("ClaudeAdapterV2 attachments", () => { assert.equal(openCount, 0); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -1928,7 +1959,12 @@ describe("ClaudeAdapterV2 native fork", () => { assert.equal(openedQueries[0]?.options.sessionId, undefined); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -2118,7 +2154,12 @@ describe("ClaudeAdapterV2 native session identity", () => { return openedQueries; }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -2343,6 +2384,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly environment?: NodeJS.ProcessEnv; readonly subagentLaunchToolUseId?: string; readonly nativeSessionId?: string; + readonly threadId?: string; readonly settings?: ClaudeSettings; // A CLI process opened after the first streams from its own queue, so the // first one can exit (Queue.shutdown) and a later turn can start another. @@ -2470,7 +2512,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { assertComplete: Effect.void, }, }); - const threadId = ThreadId.make("thread-claude-wake"); + const threadId = ThreadId.make(options?.threadId ?? "thread-claude-wake"); const runtime = yield* adapter.openSession({ threadId, providerSessionId: ProviderSessionId.make("provider-session-claude-wake"), @@ -2540,6 +2582,64 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const makeWakeHarness = makeWakeHarnessWithOptions(); + it.effect("keeps progress environments isolated across concurrent Claude queries", () => + Effect.gen(function* () { + const environment = { NODE_OPTIONS: "--trace-warnings", USER_SETTING: "keep" }; + const harnesses = yield* Effect.all( + ["one", "two"].map((name) => + makeWakeHarnessWithOptions({ + threadId: `claude-progress-${name}`, + nativeSessionId: `native-progress-${name}`, + environment, + }), + ), + { concurrency: "unbounded" }, + ); + const now = yield* DateTime.now; + yield* Effect.all( + harnesses.map((harness, index) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`progress-query-${index}`), + text: "Run a script", + attachments: [], + }), + ), + ), + { concurrency: "unbounded" }, + ); + const options = harnesses.map((harness) => harness.getOpenedOptions()?.env); + assert.equal(options[0]?.T3_SCRIPT_PROGRESS_TOKEN, "claude-progress-one"); + assert.equal(options[1]?.T3_SCRIPT_PROGRESS_TOKEN, "claude-progress-two"); + for (const option of options) { + assert.equal( + option?.NODE_OPTIONS, + "--import=file:///private/progress.mjs --trace-warnings", + ); + assert.equal(option?.USER_SETTING, "keep"); + } + assert.deepEqual(environment, { NODE_OPTIONS: "--trace-warnings", USER_SETTING: "keep" }); + assert.notStrictEqual(options[0], options[1]); + }).pipe( + Effect.scoped, + Effect.provideService(ScriptProgress.ScriptProgress, { + environmentForThread: (threadId) => + Effect.succeed({ + NODE_OPTIONS: "--import=file:///private/progress.mjs", + T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/progress", + T3_SCRIPT_PROGRESS_TOKEN: threadId, + }), + subscribe: () => Stream.empty, + }), + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect.each([ { isError: false, title: "Check weather" }, { isError: true, title: "Check weather" }, @@ -2645,7 +2745,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -2734,7 +2839,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isTrue(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -2858,7 +2968,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -2939,7 +3054,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -3013,7 +3133,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(node?.type === "node.updated" ? node.node.status : undefined, failed.status); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -3121,7 +3246,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.lengthOf(harness.terminalEvents(), 1); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -3191,7 +3321,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.notEqual(notices()[0]?.id, notices()[2]?.id); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -3242,7 +3377,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -3293,7 +3433,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.notInclude(terminal.failure.message, "repeated API errors"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -3336,7 +3481,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(terminal.failure.code, "api_error_403"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -3394,7 +3544,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(terminal.failure.class, recovered ? "provider_error" : "usage_limit"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -3458,7 +3613,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -3516,7 +3676,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -3564,7 +3729,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.notInclude(terminal.failure.message.toLowerCase(), "usage limit"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -3632,7 +3802,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual(reportedContext, [2]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -3696,7 +3871,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + Layer.mergeAll( + NodeServices.layer, + IdAllocator.layer, + Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), + ), ), ), ); @@ -3773,7 +3952,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + Layer.mergeAll( + NodeServices.layer, + IdAllocator.layer, + Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), + ), ), ), ); @@ -3876,7 +4059,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + Layer.mergeAll( + NodeServices.layer, + IdAllocator.layer, + Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), + ), ), ), ); @@ -4089,7 +4276,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4317,7 +4509,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -4348,7 +4545,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -4375,7 +4577,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -4417,7 +4624,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -4440,7 +4652,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -4455,7 +4672,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -4532,7 +4754,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* awaitUntil(() => harness.terminalEvents().length === 1, "recovered Claude turn"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4590,7 +4817,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(terminal.failure.code, "api_error_529"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4656,7 +4888,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4725,7 +4962,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4864,7 +5106,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4918,7 +5165,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.lengthOf(harness.continuationRequests, 0); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4967,7 +5219,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.lengthOf(harness.continuationRequests, 0); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -5155,7 +5412,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* awaitUntil(() => continuationRequests.length === 1, "replacement wake"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -5217,7 +5479,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -5485,7 +5752,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isFalse(yield* hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -5709,7 +5981,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -5796,7 +6073,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -5929,7 +6211,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(result?.behavior, "allow"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6029,7 +6316,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6111,7 +6403,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(harness.terminalEvents()[1]?.status, "failed"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6141,7 +6438,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.notEqual(original.promptUuid(0), copy.promptUuid(0)); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6182,7 +6484,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.include(terminal.failure.message, "never started a turn"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6235,7 +6542,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(harness.terminalEvents()[0]?.status, "completed"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6285,7 +6597,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isTrue(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6346,7 +6663,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6401,7 +6723,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.lengthOf(harness.offeredMessages, 1); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6493,7 +6820,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.include(pause.turnItem.message, "This turn is paused until the 5-hour limit"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6588,7 +6920,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6654,7 +6991,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(harness.terminalEvents()[1]?.status, "completed"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6696,7 +7038,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6783,7 +7130,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isFalse(hasMessageText(staleResultText)); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6837,7 +7189,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(harness.terminalEvents()[1]?.status, "completed"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6901,7 +7258,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.lengthOf(harness.terminalEvents(), 1); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6965,7 +7327,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7003,7 +7370,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(harness.terminalEvents()[0]?.status, "failed"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7033,7 +7405,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.lengthOf(harness.offeredMessages, 0); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7234,7 +7611,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7299,7 +7681,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7459,7 +7846,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual([...new Set(userRunItems)], ["assistant_message"]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7569,7 +7961,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7734,7 +8131,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7926,7 +8328,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -8060,7 +8467,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.lengthOf(harness.continuationRequests, 0); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -8116,7 +8528,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual(bashStatuses(), ["command_execution:running", "command_execution:failed"]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -8182,7 +8599,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -8352,7 +8774,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -8512,7 +8939,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -8593,7 +9025,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -8772,7 +9209,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.lengthOf(harness.continuationRequests, 1); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -8837,7 +9279,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual(retryStatuses, ["running"]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -8930,7 +9377,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -9017,7 +9469,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(latest.subagent.completedAt === null, resume); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -9139,7 +9596,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isFalse(yield* fixture.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -9186,7 +9648,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -9222,7 +9689,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -9281,7 +9753,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -9395,7 +9872,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(closes, 0); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -9508,7 +9990,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -9622,7 +10109,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -9726,7 +10218,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -9846,7 +10343,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual(childEfforts, ["low", "low", "max"]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -9935,7 +10437,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -10032,7 +10539,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -10322,7 +10834,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -10508,7 +11025,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -10699,7 +11221,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isFalse(yield* hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -10954,7 +11481,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isFalse(yield* hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -11123,7 +11655,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.lengthOf(processQueues, 3); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -11255,7 +11792,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isTrue(yield* hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -11459,7 +12001,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(continuationRequests[1]?.detail, "Retry build completed successfully"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -11538,7 +12085,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.lengthOf(providerThreadRosterEvents(events), 0); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -11817,7 +12369,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -11886,7 +12443,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -11994,7 +12556,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -12252,7 +12819,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -12323,7 +12895,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -12391,7 +12968,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(coordinatorCount(), betweenTurnCount + 1); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -12521,7 +13103,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -12691,7 +13278,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -12834,7 +13426,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -13068,7 +13665,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -13192,7 +13794,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -13303,7 +13910,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -13607,7 +14219,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -13675,7 +14292,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.take(harness.terminalReceipts); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -13904,7 +14526,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual(currentAssistantAnswers(), ["No answer from this workflow attempt."]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -13990,7 +14617,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -14029,7 +14661,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -14068,7 +14705,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index c84aff8913c8ff0679f34454ec85b8a6dfb06879..2a3c8766b6bec1ef113e4e32adef964c0ab74625 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; import { dynamicToolTitle, @@ -3464,6 +3465,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( adapterOptions: ClaudeAdapterV2Options, ) { const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const scriptProgress = yield* ScriptProgress.ScriptProgress; const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions; // Subagent model names and effort levels, including the instance's custom models. const subagentModelCatalog = scopeClaudeModelCatalog( @@ -8548,7 +8550,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( cwd: turnInput.runtimePolicy.cwd, attachmentsDir, settings: adapterOptions.settings, - environment: adapterOptions.environment, + environment: ScriptProgress.withScriptProgressEnvironment( + adapterOptions.environment, + yield* scriptProgress.environmentForThread(turnInput.threadId), + ), tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, ...(mcpOverrides.allowedTools === undefined ? {} @@ -9515,6 +9520,7 @@ export type ClaudeAdapterV2DriverEnv = | Crypto.Crypto | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 + | ScriptProgress.ScriptProgress | McpProviderSessions.McpProviderSessions | Path.Path | ServerConfig.ServerConfig; @@ -9608,6 +9614,7 @@ const layer: Layer.Layer< | Crypto.Crypto | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 + | ScriptProgress.ScriptProgress | McpProviderSessions.McpProviderSessions | Path.Path | ServerConfig.ServerConfig diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 8f6d0edb7eb8ac95288778afc9efb2a3dfe961f0..56ac746a9596fde7c1fada01450d7c3df2206fd2 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as NodeOS from "node:os"; import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; @@ -826,7 +827,12 @@ describe("CodexAdapterV2 process spawning", () => { assert.deepEqual(spawnedCommands, [path.join(NodeOS.homedir(), "bin", "codex")]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), Effect.provideService(HostProcess.Platform, "linux"), ), @@ -1864,6 +1870,104 @@ describe("CodexAdapterV2 session initialize", () => { ); }).pipe( Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), + ), + ), + ); + + it.effect("keeps progress credentials local to each Codex thread on a shared app-server", () => + Effect.gen(function* () { + const progressEnvironment = (threadId: string) => ({ + NODE_OPTIONS: "--import=file:///private/t3-progress.mjs", + T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/progress", + T3_SCRIPT_PROGRESS_TOKEN: threadId, + }); + const entries: Array = replayPreamble( + "progress-first", + ).slice(0, 3); + for (const [index, nativeId] of ["progress-first", "progress-second"].entries()) { + const readId = index * 2 + 2; + entries.push( + { + type: "expect_outbound", + label: "config/read", + frame: { + id: readId, + method: "config/read", + params: { cwd: "/workspace", includeLayers: false }, + }, + }, + { + type: "emit_inbound", + label: "config/read", + frame: { + id: readId, + result: { + origins: {}, + config: { + shell_environment_policy: { + set: { KEEP_ME: "yes", NODE_OPTIONS: "--trace-warnings" }, + include_only: ["PATH"], + exclude: ["SECRET_*"], + }, + }, + }, + }, + }, + { + type: "expect_outbound", + label: "thread/start", + frame: { + id: readId + 1, + method: "thread/start", + params: { + config: { + ...CodexAdapterV2.CODEX_THREAD_CONFIG, + "shell_environment_policy.set": { + KEEP_ME: "yes", + ...progressEnvironment(nativeId), + NODE_OPTIONS: "--import=file:///private/t3-progress.mjs --trace-warnings", + }, + "shell_environment_policy.include_only": [ + "PATH", + "NODE_OPTIONS", + "T3_SCRIPT_PROGRESS_ENDPOINT", + "T3_SCRIPT_PROGRESS_TOKEN", + ], + }, + }, + }, + }, + ...replayPreamble(nativeId) + .slice(4, 5) + .map((entry) => withReplayRequestId(entry, readId + 1)), + ); + } + const session = yield* openReplaySession( + makeCodexReplayTranscript({ scenario: "thread-local-progress", entries }), + ); + const first = yield* session.ensureThread("progress-first"); + const second = yield* session.ensureThread("progress-second"); + assert.equal(first.nativeThreadRef?.nativeId, "progress-first"); + assert.equal(second.nativeThreadRef?.nativeId, "progress-second"); + assert.equal(session.initializeRequests(), 1); + }).pipe( + Effect.scoped, + Effect.provideService(ScriptProgress.ScriptProgress, { + environmentForThread: (threadId) => + Effect.succeed({ + NODE_OPTIONS: "--import=file:///private/t3-progress.mjs", + T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/progress", + T3_SCRIPT_PROGRESS_TOKEN: threadId, + }), + subscribe: () => Stream.empty, + }), Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), @@ -1896,7 +2000,12 @@ describe("CodexAdapterV2 session initialize", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -1944,7 +2053,12 @@ describe("CodexAdapterV2 session initialize", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -2190,7 +2304,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -2231,7 +2350,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -2274,7 +2398,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -2313,7 +2442,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -2412,7 +2546,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -2499,7 +2638,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -2564,7 +2708,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -2628,7 +2777,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -2782,7 +2936,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isEmpty(assistantMessages(harness.events)); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -2992,7 +3151,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.lengthOf(requests(), 4); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -3090,7 +3254,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(harness.terminalEvents()[0]?.status, "completed"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -3170,7 +3339,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(harness.terminalEvents()[0]?.status, "completed"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -3222,7 +3396,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -3381,7 +3560,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -3467,7 +3651,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -3619,7 +3808,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isAbove(terminalIndex, resumedCommandIndex); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -3722,7 +3916,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -3890,7 +4089,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.deepEqual(assistantMessages(harness.events), []); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -3978,7 +4182,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4111,7 +4320,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4158,7 +4372,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4206,7 +4425,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4329,7 +4553,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4361,7 +4590,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4394,7 +4628,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4427,7 +4666,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4460,7 +4704,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4493,7 +4742,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4526,7 +4780,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4686,7 +4945,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -4845,7 +5109,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.lengthOf(harness.continuationRequests, 0); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -5209,7 +5478,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.lengthOf(harness.terminalEvents(), 1); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -5502,7 +5776,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(commandUpdates[commandUpdates.length - 1]?.turnItem.status, "interrupted"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -5734,7 +6013,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -5823,7 +6107,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -5915,7 +6204,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6030,7 +6324,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6199,7 +6498,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6468,7 +6772,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6579,7 +6888,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6819,7 +7133,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -6921,7 +7240,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.lengthOf(harness.continuationRequests, 0); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7086,7 +7410,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7176,7 +7505,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7422,7 +7756,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7660,7 +7999,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(metadataRequests, name === "current Codex Sol" ? 1 : 2); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7721,7 +8065,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.deepEqual(metadataRequests, ["thread/read", "thread/resume"]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7824,7 +8173,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7890,7 +8244,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.lengthOf(harness.continuationRequests, 0); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -7974,7 +8333,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(DateTime.toEpochMillis(row!.startedAt!), 1782622470000); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -8140,7 +8504,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), @@ -8293,7 +8662,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -8437,7 +8811,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -8544,7 +8923,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -8605,7 +8989,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -8774,7 +9163,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -8823,7 +9217,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -8919,7 +9318,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -9003,7 +9407,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -9110,7 +9519,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -9197,7 +9611,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -9299,7 +9718,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -9417,7 +9841,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); @@ -9486,7 +9915,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 7f55417ea2ac0d35b6bcd87a1b9d3afd80f5b10a..c2d95be7fd2507edda2fee0d21ff794e337d7455 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1,3 +1,5 @@ +import { codexScriptProgressConfig } from "./codexScriptProgress.ts"; +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import { revertCodexThread } from "../../provider/CodexThreadRevert.ts"; import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; @@ -1332,6 +1334,7 @@ export function codexThreadRuntimeParams(input: { readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined; readonly modelSelection?: { readonly model: string }; readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy; + readonly scriptProgressConfig?: Readonly>; }): { readonly cwd?: string; readonly model?: string; @@ -1343,6 +1346,7 @@ export function codexThreadRuntimeParams(input: { ...(input.modelSelection === undefined ? {} : { model: input.modelSelection.model }), config: { ...CODEX_THREAD_CONFIG, + ...input.scriptProgressConfig, ...(mcpSession === undefined ? {} : { @@ -1592,6 +1596,7 @@ export type CodexAdapterV2DriverEnv = | Crypto.Crypto | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 + | ScriptProgress.ScriptProgress | McpProviderSessions.McpProviderSessions | Path.Path | ServerConfig; @@ -1657,6 +1662,7 @@ const layer: Layer.Layer< | Crypto.Crypto | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 + | ScriptProgress.ScriptProgress | McpProviderSessions.McpProviderSessions | ServerConfig > = Layer.effect( @@ -1716,6 +1722,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( adapterOptions: CodexAdapterV2Options, ) { const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const scriptProgress = yield* ScriptProgress.ScriptProgress; const readMcpSession = (threadId: ThreadId | null) => threadId === null ? Effect.succeed(undefined) : mcpSessions.read(threadId); const { clientFactory, crypto, fileSystem, idAllocator, serverConfig } = adapterOptions; @@ -1827,6 +1834,24 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* Ref.set(initialized, true); }), ); + const readScriptProgressConfig = Effect.fnUntraced(function* ( + threadId: ThreadId | null, + cwd: string | null | undefined, + ) { + if (threadId === null) return {}; + const environment = yield* scriptProgress.environmentForThread(threadId); + if (Object.keys(environment).length === 0) return {}; + yield* ensureInitialized; + const { config } = yield* client.request("config/read", { + ...(cwd == null ? {} : { cwd }), + includeLayers: false, + }); + return codexScriptProgressConfig( + config.shell_environment_policy, + environment, + (resolvedRuntime?.environment ?? adapterOptions.environment).NODE_OPTIONS, + ); + }); const now = yield* DateTime.now; const session = providerSession({ providerSessionId: input.providerSessionId, @@ -6567,14 +6592,20 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ensureInitialized.pipe( Effect.andThen(mcpSessions.read(threadInput.threadId)), Effect.flatMap((mcpSession) => - client.request( - "thread/start", - codexThreadRuntimeParams({ - mcpSession, - modelSelection: threadInput.modelSelection, - runtimePolicy: threadInput.runtimePolicy, - }), - ), + Effect.gen(function* () { + return yield* client.request( + "thread/start", + codexThreadRuntimeParams({ + mcpSession, + scriptProgressConfig: yield* readScriptProgressConfig( + threadInput.threadId, + threadInput.runtimePolicy?.cwd, + ), + modelSelection: threadInput.modelSelection, + runtimePolicy: threadInput.runtimePolicy, + }), + ); + }), ), Effect.map((response): OrchestrationV2ProviderThread => providerThreadFromCodexThread({ @@ -6606,6 +6637,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( mcpSession: yield* readMcpSession( threadInput.threadId ?? threadInput.providerThread.appThreadId, ), + scriptProgressConfig: yield* readScriptProgressConfig( + threadInput.threadId ?? threadInput.providerThread.appThreadId, + threadInput.runtimePolicy?.cwd ?? input.runtimePolicy.cwd, + ), ...(threadInput.modelSelection === undefined ? {} : { modelSelection: threadInput.modelSelection }), @@ -7335,6 +7370,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( excludeTurns: true, ...codexThreadRuntimeParams({ mcpSession: yield* readMcpSession(threadInput.providerThread.appThreadId), + scriptProgressConfig: yield* readScriptProgressConfig( + threadInput.providerThread.appThreadId, + input.runtimePolicy.cwd, + ), modelSelection: input.modelSelection, runtimePolicy: input.runtimePolicy, }), @@ -7384,6 +7423,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( : { lastTurnId: boundary.lastTurnId }), ...codexThreadRuntimeParams({ mcpSession: yield* mcpSessions.read(threadInput.targetThreadId), + scriptProgressConfig: yield* readScriptProgressConfig( + threadInput.targetThreadId, + threadInput.runtimePolicy?.cwd ?? input.runtimePolicy.cwd, + ), ...(threadInput.modelSelection === undefined ? {} : { modelSelection: threadInput.modelSelection }), diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index 1a1391ca93638514dc30f2fa2da5b083853b0b26..90b25d8402f5e5d10668ed0a07964398af0e52a0 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProjectId, @@ -326,6 +327,7 @@ describe("Grok launch permission mode", () => { NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer, + ScriptProgress.layerUnavailable, layerHost, ); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 548ff5cb6643ba8c176b4d107639322b9505d10c..3c60f433175e92db63960f6d8c35821cb8bd90c1 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; /** * Failures and settings a live OpenCode 2 server cannot produce on demand, * driven through the real adapter and `@opencode/client` against a replayed @@ -423,7 +424,9 @@ const history = { cursor: {}, }; -it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { +const layerThreadServices = Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable); + +it.layer(layerThreadServices)("OpenCode2 adapter", (it) => { it.effect("switches the session's model and variant before a turn that changed them", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -5243,7 +5246,7 @@ describe("OpenCode2 adapter server connection", () => { assert.equal(borrowers.current, 1); }).pipe(Effect.scoped); assert.equal(borrowers.current, 0); - }).pipe(Effect.provide(McpProviderSessions.layer)), + }).pipe(Effect.provide(layerThreadServices)), ); }); @@ -5256,7 +5259,7 @@ const providerTurnId = Effect.gen(function* () { }); }).pipe(Effect.provide(IdAllocator.layer)); -it.layer(McpProviderSessions.layer)("OpenCode reported model variants", (it) => { +it.layer(layerThreadServices)("OpenCode reported model variants", (it) => { it.effect( "updates reported variants from selected-model and step events without duplicate updates", () => diff --git a/apps/server/src/orchestration-v2/Adapters/codexScriptProgress.test.ts b/apps/server/src/orchestration-v2/Adapters/codexScriptProgress.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ebf83dea2757ab6f1334863b758b03ecf38ee16 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/codexScriptProgress.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vite-plus/test"; +import { codexScriptProgressConfig } from "./codexScriptProgress.ts"; + +const progress = { + NODE_OPTIONS: '--import="/private/progress.mjs"', + T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/ingest", + T3_SCRIPT_PROGRESS_TOKEN: "thread-one", +}; + +describe("Codex script progress environment", () => { + it("preserves user overrides and Node options without weakening exclusions", () => { + const policy = { + inherit: "core", + ignore_default_excludes: false, + exclude: ["PRIVATE_*"], + include_only: ["PATH", "MY_SETTING"], + set: { MY_SETTING: "keep", NODE_OPTIONS: "--trace-warnings" }, + }; + expect(codexScriptProgressConfig(policy, progress, "--max-old-space-size=2048")).toEqual({ + "shell_environment_policy.set": { + MY_SETTING: "keep", + ...progress, + NODE_OPTIONS: '--import="/private/progress.mjs" --max-old-space-size=2048 --trace-warnings', + }, + "shell_environment_policy.include_only": [ + "PATH", + "MY_SETTING", + "NODE_OPTIONS", + "T3_SCRIPT_PROGRESS_ENDPOINT", + "T3_SCRIPT_PROGRESS_TOKEN", + ], + }); + expect(policy.set.NODE_OPTIONS).toBe("--trace-warnings"); + expect(policy.include_only).toEqual(["PATH", "MY_SETTING"]); + }); + + it("accepts null optional fields returned by Codex config/read", () => { + expect( + codexScriptProgressConfig( + { + inherit: null, + ignore_default_excludes: null, + exclude: null, + set: {}, + include_only: null, + filters: null, + experimental_use_profile: null, + }, + progress, + ), + ).toEqual({ "shell_environment_policy.set": progress }); + expect(codexScriptProgressConfig({ set: null, include_only: null }, progress)).toEqual({ + "shell_environment_policy.set": progress, + }); + }); + + it("does not create an allowlist when the user inherits the normal environment", () => { + expect(codexScriptProgressConfig(undefined, progress)).toEqual({ + "shell_environment_policy.set": progress, + }); + expect(codexScriptProgressConfig({ include_only: [] }, progress)).toEqual({ + "shell_environment_policy.set": progress, + }); + }); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/codexScriptProgress.ts b/apps/server/src/orchestration-v2/Adapters/codexScriptProgress.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1a9f84009b2438f87ae3034b353cbf905e18070 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/codexScriptProgress.ts @@ -0,0 +1,36 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; +import * as Schema from "effect/Schema"; + +const ShellEnvironmentPolicy = Schema.StructWithRest( + Schema.Struct({ + set: Schema.optionalKey(Schema.NullOr(Schema.Record(Schema.String, Schema.String))), + include_only: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + }), + [Schema.Record(Schema.String, Schema.Json)], +); + +const decodeShellEnvironmentPolicy = Schema.decodeUnknownSync(ShellEnvironmentPolicy); + +export function codexScriptProgressConfig( + policy: Schema.Json | undefined, + environment: Readonly>, + inheritedNodeOptions?: string, +): Readonly> { + const current = decodeShellEnvironmentPolicy(policy ?? {}); + const nodeOptions = [inheritedNodeOptions, current.set?.NODE_OPTIONS].filter(Boolean).join(" "); + const shellEnvironment = { + ...current.set, + ...(nodeOptions ? { NODE_OPTIONS: nodeOptions } : {}), + }; + const merged = ScriptProgress.withScriptProgressEnvironment(shellEnvironment, environment); + return { + "shell_environment_policy.set": merged, + ...(current.include_only == null || current.include_only.length === 0 + ? {} + : { + "shell_environment_policy.include_only": [ + ...new Set([...current.include_only, ...Object.keys(environment)]), + ], + }), + }; +} diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index 3fd034abe4f27c5de884126577f5679596b57f32..cebd15a3389c9c591758cefac2d8750b46d734ee 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -434,7 +435,12 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + NodeServices.layer, + ), ), ), ), diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts index c17888f5e0532a8a744fe8988d629c0180805227..de3bbba14cd1c52ed96f07b4083aabfbf4a7114e 100644 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -125,7 +126,7 @@ const layerLive = RuntimeLayer.layer.pipe( Layer.provide(ResetCreditCoordinator.layer), Layer.provide(layerBackgroundPolicy), Layer.provide(layerPlatformTest), - Layer.provide(McpProviderSessions.layer), + Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), ); const waitForIdle = Effect.fn("CursorOrchestratorV2Live.waitForIdle")(function* ( diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index f31d1bb5d36b43d487d91a249f590b9bf6151671..6e9ded48115b5643dd9e756845bdc72d387e5635 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -133,7 +134,7 @@ const layerTest = Layer.mergeAll(RuntimeLayer.layer, RuntimeLayer.layerEventSink }), ), Layer.provide(McpSessionRegistryTestkit.layer), - Layer.provide(McpProviderSessions.layer), + Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), Layer.provide(SqlitePersistence.layerMemory), Layer.provide(layerCheckpointStoreTest), Layer.provide(layerServerConfig), diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts index f3d3bb75ced63876aed8388177812b9b859dd71b..0d3a242a0f307a44a6c866d290ffd5c20c6c5ee2 100644 --- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -124,7 +125,7 @@ const layerLive = RuntimeLayer.layer.pipe( Layer.provide(ResetCreditCoordinator.layer), Layer.provide(layerBackgroundPolicy), Layer.provide(layerPlatformTest), - Layer.provide(McpProviderSessions.layer), + Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), ); const waitForIdle = Effect.fn("GrokOrchestratorV2Live.waitForIdle")(function* (threadId: ThreadId) { diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 0a39c5cf374a26634b747a9185f865cc3fe96ad9..44c34479b6d346f69ebf079c5f7898526cee043d 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; /** * Runs OpenCode 2 through the whole orchestrator with the real driver: the * driver probes the binary, spawns `opencode serve`, and routes to the 2.x @@ -218,7 +219,7 @@ const layerOrchestration = RuntimeLayer.layer.pipe( Layer.provide(ResetCreditCoordinator.layer), Layer.provide(layerBackgroundPolicy), Layer.provide(layerPlatformTest), - Layer.provide(McpProviderSessions.layer), + Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), ); // Starts the continuation run a provider wake asks for, as the production layer does. diff --git a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts index c5eeeee6698e850d15fe23486a752e6527f2df31..0672e545b38d6ab75952fed88c46093db8dff7dc 100644 --- a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts +++ b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { assert, it } from "@effect/vitest"; @@ -153,7 +154,7 @@ const layerRuntime = (dbPath: string) => { }), ), Layer.provide(McpSessionRegistryTestkit.layer), - Layer.provide(McpProviderSessions.layer), + Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), Layer.provideMerge(SqlitePersistence.layerFromPath(dbPath)), Layer.provide(layerCheckpointStore), Layer.provide(layerServerConfig), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 62a03c0de2ab66322846175aef6ba059c2a03a00..4b16cfd6b043408e7ef81a88977664b1eb3c1b5d 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as NetAddress from "effect/net/NetAddress"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -553,13 +554,18 @@ function layerTest(input: { IdAllocator.layer, layerConfiguredMcpRegistry, McpProviderSessions.layer, + ScriptProgress.layerUnavailable, ProviderSessionManager.layerWithOptions({ idleTimeoutMs: input.idleTimeoutMs, ...(input.maxIdlePinMs === undefined ? {} : { maxIdlePinMs: input.maxIdlePinMs }), }).pipe( Layer.provide( Layer.mergeAll( - layerRegistry.pipe(Layer.provideMerge(McpProviderSessions.layer)), + layerRegistry.pipe( + Layer.provideMerge( + Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), + ), + ), layerConfiguredEventSink, IdAllocator.layer, layerProviderEventIngestorTest, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index d86a042e173dcc3a0a26fe7a97c88c1a31d50e8f..688af8405ef5aa65356e76f90469379e63c7e15b 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import { SourceControlProviderKind } from "@t3tools/contracts"; import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -274,7 +275,7 @@ const layerTest = Layer.mergeAll( ThreadCommandExecutor.layer, ).pipe( Layer.provide(McpSessionRegistryTestkit.layer), - Layer.provide(McpProviderSessions.layer), + Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), Layer.provide(SqlitePersistence.layerMemory), Layer.provide(layerCheckpointStoreTest), Layer.provide(layerServerConfig), @@ -287,7 +288,7 @@ const layerTest = Layer.mergeAll( const layerLegacyImportTest = RuntimeLayer.layer.pipe( Layer.provide(McpSessionRegistryTestkit.layer), - Layer.provide(McpProviderSessions.layer), + Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), Layer.provideMerge(SqlitePersistence.layerMemory), Layer.provide(layerCheckpointStoreTest), Layer.provide(layerServerConfig), @@ -327,7 +328,7 @@ const layerProjectDeletionTest = Layer.mergeAll( }), ), Layer.provide(McpSessionRegistryTestkit.layer), - Layer.provide(McpProviderSessions.layer), + Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), Layer.provide(SqlitePersistence.layerMemory), Layer.provide(layerCheckpointStoreTest), Layer.provide(layerServerConfig), @@ -472,7 +473,7 @@ const layerSharedApplicationDataPlaneTest = Layer.mergeAll( }), ), Layer.provide(McpSessionRegistryTestkit.layer), - Layer.provide(McpProviderSessions.layer), + Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), Layer.provideMerge(SqlitePersistence.layerMemory), Layer.provide(layerCheckpointStoreTest), Layer.provide(layerServerConfig), @@ -5710,7 +5711,7 @@ const layerQueueResumeDisabledInstanceTest = Layer.mergeAll( ThreadCommandExecutor.layer, ).pipe( Layer.provide(McpSessionRegistryTestkit.layer), - Layer.provide(McpProviderSessions.layer), + Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), Layer.provide(SqlitePersistence.layerMemory), Layer.provide(layerCheckpointStoreTest), Layer.provide(layerServerConfig), diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 1ee5fb9964843dde1fe323ee80c4bb0227a70895..491a6446a4360dbafc791a3be40d76b6699ba5b1 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -174,7 +175,7 @@ export interface OrchestratorV2ProviderReplayHarness< ) => Layer.Layer< ProviderAdapterRegistry.ProviderAdapterRegistryV2, Error, - McpProviderSessions.McpProviderSessions + McpProviderSessions.McpProviderSessions | ScriptProgress.ScriptProgress >; } @@ -260,7 +261,7 @@ export function layerWithRegistry( registryLayer: Layer.Layer< ProviderAdapterRegistry.ProviderAdapterRegistryV2, Error, - McpProviderSessions.McpProviderSessions + McpProviderSessions.McpProviderSessions | ScriptProgress.ScriptProgress >, options: { readonly databaseLayer?: Layer.Layer< @@ -299,7 +300,13 @@ export function layerWithRegistry( const layerContinuationRequests = options.runContinuationWorker === true ? ProviderContinuationRequests.layer : Layer.empty; const layerProvidedRegistry = registryLayer.pipe( - Layer.provide(Layer.merge(layerContinuationRequests, McpProviderSessions.layer)), + Layer.provide( + Layer.mergeAll( + layerContinuationRequests, + McpProviderSessions.layer, + ScriptProgress.layerUnavailable, + ), + ), ); const layerServerSettings = ServerSettings.layerTest({ responseStreamingMode: "turn", @@ -362,6 +369,7 @@ export function layerWithRegistry( layerEventSinkProvided, IdAllocator.layer, McpProviderSessions.layer, + ScriptProgress.layerUnavailable, McpSessionRegistryTestkit.layer, layerProviderEventIngestorProvided, layerStores, diff --git a/apps/server/src/progress/ScriptProgress.test.ts b/apps/server/src/progress/ScriptProgress.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..012c18c2275834772b2bced9f7707606fbe0136c --- /dev/null +++ b/apps/server/src/progress/ScriptProgress.test.ts @@ -0,0 +1,537 @@ +// @effect-diagnostics nodeBuiltinImport:off -- A native child validates NODE_OPTIONS before application code runs. +import * as NodeNet from "node:net"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Root, decodeEventStream, type StreamEvent } from "@clo/lib/progress"; +import { ThreadId, type ScriptProgressEvent } from "@t3tools/contracts"; +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../config.ts"; +import * as Capture from "./ScriptProgress.ts"; + +const threadId = ThreadId.make("progress-thread"); +const otherThreadId = ThreadId.make("other-progress-thread"); +const testLayer = Capture.layer.pipe( + Layer.provide(ServerConfig.layerTest("/tmp", { prefix: "t3 script progress " })), + Layer.provideMerge(NodeServices.layer), +); + +const connect = (endpoint: string) => + Effect.acquireRelease( + Effect.callback((resume) => { + const socket = NodeNet.connect({ host: "127.0.0.1", port: Number(endpoint.split(":")[1]) }); + socket.once("connect", () => resume(Effect.succeed(socket))); + socket.once("error", (error) => resume(Effect.die(error))); + return Effect.sync(() => socket.destroy()); + }), + (socket) => Effect.sync(() => socket.destroy()), + ); + +const closed = (socket: NodeNet.Socket) => + Effect.callback((resume) => { + if (socket.closed) return resume(Effect.void); + socket.once("close", () => resume(Effect.void)); + }); + +const write = (socket: NodeNet.Socket, value: unknown) => + Effect.sync(() => { + socket.write(`${JSON.stringify(value)}\n`); + }); + +const until = ( + pull: Effect.Effect, + predicate: (event: ScriptProgressEvent) => boolean, +) => + Effect.gen(function* () { + while (true) { + const event = yield* pull; + if (predicate(event)) return event; + } + }); + +it.effect("materializes an external preload and gives each thread an isolated capture token", () => + Effect.gen(function* () { + const progress = yield* ScriptProgress.ScriptProgress; + const first = yield* progress.environmentForThread(threadId); + const again = yield* progress.environmentForThread(threadId); + const other = yield* progress.environmentForThread(otherThreadId); + expect(first).toEqual(again); + expect(first.T3_SCRIPT_PROGRESS_TOKEN).not.toBe(other.T3_SCRIPT_PROGRESS_TOKEN); + expect(first.T3_SCRIPT_PROGRESS_ENDPOINT).toMatch(/^127\.0\.0\.1:\d+$/); + const fs = yield* FileSystem.FileSystem; + const preloadPath: unknown = JSON.parse(first.NODE_OPTIONS!.slice("--require=".length)); + if (typeof preloadPath !== "string") throw new Error("Expected preload path"); + const stat = yield* fs.stat(preloadPath); + expect(Number(stat.size)).toBeGreaterThan(100); + expect(preloadPath).not.toContain(".asar/"); + expect(preloadPath).toContain("t3 script progress "); + expect(preloadPath).toMatch(/\.cjs$/); + const environment = ScriptProgress.withScriptProgressEnvironment( + { + ...process.env, + NODE_OPTIONS: "--max-old-space-size=128", + }, + first, + ); + expect(ScriptProgress.withScriptProgressEnvironment(environment, first)).toEqual(environment); + const result = yield* Effect.callback<{ code: number | null; stdout: string; stderr: string }>( + (resume) => { + const child = NodeChildProcess.spawn( + process.execPath, + ["-e", "process.stdout.write('preload-loaded')"], + { env: environment }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.once("error", (error) => resume(Effect.die(error))); + child.once("close", (code) => resume(Effect.succeed({ code, stdout, stderr }))); + return Effect.sync(() => { + child.kill(); + }); + }, + ); + expect(result).toEqual({ code: 0, stdout: "preload-loaded", stderr: "" }); + }).pipe(Effect.scoped, Effect.provide(testLayer)), +); + +it.effect( + "late subscribers get a current reset snapshot with nested nodes and subsequent zero deltas", + () => + Effect.gen(function* () { + const progress = yield* ScriptProgress.ScriptProgress; + const env = yield* progress.environmentForThread(threadId); + const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); + const pull = Queue.take(queue); + expect(yield* pull).toEqual({ type: "snapshot", streams: [] }); + const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); + yield* write(socket, { + type: "hello", + producerId: "fixture", + token: env.T3_SCRIPT_PROGRESS_TOKEN, + pid: 123, + label: "build.ts", + }); + yield* write(socket, { + type: "progress", + streamId: "global", + event: [ + -1, + { k: 1, t: "Build", v: 2, e: 10, c: [2] }, + { k: 2, t: "Assets", v: 1, e: 3 }, + [1], + ], + }); + yield* until(pull, (event) => event.type === "progress" && event.summary[0]?.value === 2); + yield* write(socket, { type: "progress", streamId: "global", event: [0, { k: 1, v: 7 }] }); + yield* until(pull, (event) => event.type === "progress" && event.summary[0]?.value === 7); + + const lateQueue = yield* Stream.toQueue(progress.subscribe(threadId), { + capacity: "unbounded", + }); + const late = Queue.take(lateQueue); + const snapshot = yield* late; + expect(snapshot).toMatchObject({ + type: "snapshot", + streams: [ + { + pid: 123, + label: "build.ts", + status: "connected", + summary: [{ text: "Build", value: 7, total: 10 }], + }, + ], + }); + const reset = yield* until(late, (event) => event.type === "progress"); + if (reset.type !== "progress") throw new Error("Expected progress"); + expect(reset.event[0]).toBe(-1); + const root = new Root(); + let input!: ReadableStreamDefaultController; + const decoder = decodeEventStream( + new ReadableStream({ + start(controller) { + input = controller; + }, + }), + root, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => decoder.cancel())); + const decoded = new Promise((resolve) => + root.on("node-start", () => { + if (root.active[0]?.children.length === 1) resolve(); + }), + ); + input.enqueue(reset.event as StreamEvent); + yield* Effect.promise(() => decoded); + expect(root.active[0]?.value).toBe(7); + expect(root.active[0]?.children[0]?.text).toBe("Assets"); + yield* write(socket, { type: "progress", streamId: "global", event: [0, { k: 1, v: 0 }] }); + yield* until(late, (event) => event.type === "progress" && event.summary[0]?.value === 0); + yield* write(socket, { type: "progress", streamId: "global", event: [0, ["end", null]] }); + yield* until(late, (event) => event.type === "remove"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), +); + +it.effect("rejects unknown tokens and malformed producers without affecting another thread", () => + Effect.gen(function* () { + const progress = yield* ScriptProgress.ScriptProgress; + const env = yield* progress.environmentForThread(threadId); + const wrong = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); + yield* write(wrong, { type: "hello", producerId: "fixture", token: "wrong", pid: 12 }); + yield* closed(wrong); + const malformed = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); + yield* Effect.sync(() => malformed.write("not json\n")); + yield* closed(malformed); + const valid = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); + const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); + const pull = Queue.take(queue); + yield* pull; + yield* write(valid, { + type: "hello", + producerId: "fixture", + token: env.T3_SCRIPT_PROGRESS_TOKEN, + pid: 13, + }); + yield* write(valid, { + type: "progress", + streamId: "global", + event: [-1, { k: 1, t: "Good" }, [1]], + }); + yield* until(pull, (event) => event.type === "progress" && event.summary[0]?.text === "Good"); + const otherQueue = yield* Stream.toQueue(progress.subscribe(otherThreadId), { + capacity: "unbounded", + }); + const other = Queue.take(otherQueue); + expect(yield* other).toEqual({ type: "snapshot", streams: [] }); + }).pipe(Effect.scoped, Effect.provide(testLayer)), +); + +it.effect( + "coalesces a burst into current progress and labels disconnected producers as unknown", + () => + Effect.gen(function* () { + const progress = yield* ScriptProgress.ScriptProgress; + const env = yield* progress.environmentForThread(threadId); + const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); + const pull = Queue.take(queue); + yield* pull; + const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); + yield* write(socket, { + type: "hello", + producerId: "fixture", + token: env.T3_SCRIPT_PROGRESS_TOKEN, + pid: 14, + }); + yield* write(socket, { + type: "progress", + streamId: "global", + event: [-1, { k: 1, t: "Burst", e: 1000 }, [1]], + }); + yield* until( + pull, + (event) => event.type === "progress" && event.summary[0]?.text === "Burst", + ); + yield* Effect.sync(() => { + socket.write( + Array.from({ length: 100 }, (_, i) => + JSON.stringify({ + type: "progress", + streamId: "global", + event: [0, { k: 1, v: i + 1 }], + }), + ).join("\n") + "\n", + ); + }); + yield* until(pull, (event) => event.type === "progress" && event.summary[0]?.value === 100); + yield* Effect.sync(() => socket.destroy()); + const event = yield* until( + pull, + (event) => event.type === "upsert" && event.stream.status === "disconnected", + ); + expect(event).toMatchObject({ type: "upsert", stream: { status: "disconnected" } }); + const lateQueue = yield* Stream.toQueue(progress.subscribe(threadId), { + capacity: "unbounded", + }); + const late = Queue.take(lateQueue); + expect(yield* late).toMatchObject({ + type: "snapshot", + streams: [{ status: "disconnected", summary: [{ text: "Burst", value: 100 }] }], + }); + }).pipe(Effect.scoped, Effect.provide(testLayer)), +); + +it.effect("destroys oversized input without retaining a capture", () => + Effect.gen(function* () { + const progress = yield* ScriptProgress.ScriptProgress; + const env = yield* progress.environmentForThread(threadId); + const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); + yield* write(socket, { + type: "hello", + producerId: "fixture", + token: env.T3_SCRIPT_PROGRESS_TOKEN, + pid: 15, + }); + yield* Effect.sync(() => socket.write("x".repeat(1024 * 1024 + 1))); + yield* closed(socket); + const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); + const pull = Queue.take(queue); + expect(yield* pull).toEqual({ type: "snapshot", streams: [] }); + }).pipe(Effect.scoped, Effect.provide(testLayer)), +); + +it.effect("bounds accumulated state without treating a broken producer as completed", () => + Effect.gen(function* () { + const progress = yield* ScriptProgress.ScriptProgress; + const env = yield* progress.environmentForThread(threadId); + const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); + const pull = Queue.take(queue); + yield* pull; + const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); + yield* write(socket, { + type: "hello", + producerId: "fixture", + token: env.T3_SCRIPT_PROGRESS_TOKEN, + pid: 16, + }); + yield* write(socket, { + type: "progress", + streamId: "global", + event: [-1, { k: 1, t: "a".repeat(400_000) }, [1]], + }); + yield* until(pull, (event) => event.type === "progress" && event.summary.length === 1); + yield* write(socket, { + type: "progress", + streamId: "global", + event: [ + 0, + { k: 1, c: [2, 3] }, + { k: 2, t: "b".repeat(400_000) }, + { k: 3, t: "c".repeat(400_000) }, + ], + }); + yield* until(pull, (event) => event.type === "remove"); + yield* closed(socket); + const lateQueue = yield* Stream.toQueue(progress.subscribe(threadId), { + capacity: "unbounded", + }); + expect(yield* Queue.take(lateQueue)).toEqual({ type: "snapshot", streams: [] }); + }).pipe(Effect.scoped, Effect.provide(testLayer)), +); + +it.effect("retains only the most recent sixteen disconnected roots for a thread", () => + Effect.gen(function* () { + const progress = yield* ScriptProgress.ScriptProgress; + const env = yield* progress.environmentForThread(threadId); + const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); + const pull = Queue.take(queue); + yield* pull; + const producers: NodeNet.Socket[] = []; + for (let i = 0; i < 17; i++) { + const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); + yield* write(socket, { + type: "hello", + producerId: `producer-${i}`, + token: env.T3_SCRIPT_PROGRESS_TOKEN, + pid: 100 + i, + }); + yield* write(socket, { + type: "progress", + streamId: "global", + event: [-1, { k: 1, t: "Retained" }, [1]], + }); + yield* until(pull, (event) => event.type === "upsert" && event.stream.status === "connected"); + producers.push(socket); + } + for (const socket of producers) { + yield* Effect.sync(() => socket.destroy()); + yield* until( + pull, + (event) => event.type === "upsert" && event.stream.status === "disconnected", + ); + } + const lateQueue = yield* Stream.toQueue(progress.subscribe(threadId), { + capacity: "unbounded", + }); + const snapshot = yield* Queue.take(lateQueue); + if (snapshot.type !== "snapshot") throw new Error("Expected snapshot"); + expect(snapshot.streams).toHaveLength(16); + expect(snapshot.streams.map((capture) => capture.pid)).toEqual( + Array.from({ length: 16 }, (_, i) => 101 + i), + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), +); + +it.effect("closes the loopback listener when its application scope ends", () => + Effect.gen(function* () { + const endpoint = yield* Effect.gen(function* () { + const progress = yield* ScriptProgress.ScriptProgress; + return (yield* progress.environmentForThread(threadId)).T3_SCRIPT_PROGRESS_ENDPOINT!; + }).pipe(Effect.provide(testLayer)); + const code = yield* Effect.callback((resume) => { + const socket = NodeNet.connect({ host: "127.0.0.1", port: Number(endpoint.split(":")[1]) }); + socket.once("connect", () => resume(Effect.die("Progress listener outlived its scope"))); + socket.once("error", (error: NodeJS.ErrnoException) => resume(Effect.succeed(error.code))); + return Effect.sync(() => socket.destroy()); + }); + expect(code).toBe("ECONNREFUSED"); + }), +); + +it.effect( + "replaces a reconnected root while preserving another producer with the same PID and another thread", + () => + Effect.gen(function* () { + const progress = yield* ScriptProgress.ScriptProgress; + const env = yield* progress.environmentForThread(threadId); + const otherEnv = yield* progress.environmentForThread(otherThreadId); + const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); + const pull = Queue.take(queue); + yield* pull; + const original = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); + yield* write(original, { + type: "hello", + token: env.T3_SCRIPT_PROGRESS_TOKEN, + producerId: "producer-a", + pid: 42, + }); + yield* write(original, { + type: "progress", + streamId: "root", + event: [-1, { k: 1, t: "Original", v: 7 }, [1]], + }); + const originalEvent = yield* until( + pull, + (event) => event.type === "progress" && event.summary[0]?.text === "Original", + ); + if (originalEvent.type !== "progress") throw new Error("Expected original progress"); + + const worker = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); + yield* write(worker, { + type: "hello", + token: env.T3_SCRIPT_PROGRESS_TOKEN, + producerId: "producer-b", + pid: 42, + }); + yield* write(worker, { + type: "progress", + streamId: "root", + event: [-1, { k: 1, t: "Worker" }, [1]], + }); + yield* until( + pull, + (event) => event.type === "progress" && event.summary[0]?.text === "Worker", + ); + const otherQueue = yield* Stream.toQueue(progress.subscribe(otherThreadId), { + capacity: "unbounded", + }); + const otherPull = Queue.take(otherQueue); + yield* otherPull; + const other = yield* connect(otherEnv.T3_SCRIPT_PROGRESS_ENDPOINT!); + yield* write(other, { + type: "hello", + token: otherEnv.T3_SCRIPT_PROGRESS_TOKEN, + producerId: "producer-a", + pid: 42, + }); + yield* write(other, { + type: "progress", + streamId: "root", + event: [-1, { k: 1, t: "Other thread" }, [1]], + }); + yield* until( + otherPull, + (event) => event.type === "progress" && event.summary[0]?.text === "Other thread", + ); + + yield* Effect.sync(() => original.destroy()); + yield* until( + pull, + (event) => + event.type === "upsert" && + event.stream.id === originalEvent.id && + event.stream.status === "disconnected", + ); + const reconnected = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); + yield* write(reconnected, { + type: "hello", + token: env.T3_SCRIPT_PROGRESS_TOKEN, + producerId: "producer-a", + pid: 42, + }); + yield* write(reconnected, { + type: "progress", + streamId: "root", + event: [-1, { k: 1, t: "Reconnected", v: 8 }, [1]], + }); + yield* until( + pull, + (event) => event.type === "progress" && event.summary[0]?.text === "Reconnected", + ); + const late = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); + const snapshot = yield* Queue.take(late); + if (snapshot.type !== "snapshot") throw new Error("Expected snapshot"); + expect(snapshot.streams).toHaveLength(2); + expect(snapshot.streams.every((stream) => stream.status === "connected")).toBe(true); + expect(snapshot.streams.map((stream) => stream.summary[0]?.text)).toEqual([ + "Worker", + "Reconnected", + ]); + expect(snapshot.streams.some((stream) => stream.id === originalEvent.id)).toBe(false); + const otherLate = yield* Stream.toQueue(progress.subscribe(otherThreadId), { + capacity: "unbounded", + }); + expect(yield* Queue.take(otherLate)).toMatchObject({ + type: "snapshot", + streams: [{ status: "connected", summary: [{ text: "Other thread" }] }], + }); + }).pipe(Effect.scoped, Effect.provide(testLayer)), +); + +it.effect("uses the library visibility rule for native summaries", () => + Effect.gen(function* () { + const progress = yield* ScriptProgress.ScriptProgress; + const env = yield* progress.environmentForThread(threadId); + const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); + const pull = Queue.take(queue); + yield* pull; + const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); + yield* write(socket, { + type: "hello", + token: env.T3_SCRIPT_PROGRESS_TOKEN, + producerId: "visibility", + pid: 45, + }); + yield* write(socket, { + type: "progress", + streamId: "root", + event: [ + -1, + { k: 1, t: "Empty group", p: true }, + { k: 2, t: "Hidden", h: true }, + { k: 3, t: "Visible", v: 2, e: 5 }, + [1, 2, 3], + ], + }); + const event = yield* until( + pull, + (event) => event.type === "progress" && event.summary.length > 0, + ); + expect(event).toMatchObject({ + type: "progress", + summary: [{ text: "Visible", value: 2, total: 5 }], + }); + if (event.type !== "progress") throw new Error("Expected progress"); + expect(event.summary).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(testLayer)), +); diff --git a/apps/server/src/progress/ScriptProgress.ts b/apps/server/src/progress/ScriptProgress.ts new file mode 100644 index 0000000000000000000000000000000000000000..13df184668a4684d838f510dcaeaf2696a9d01e1 --- /dev/null +++ b/apps/server/src/progress/ScriptProgress.ts @@ -0,0 +1,374 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Socket callbacks allocate opaque capture identifiers synchronously. +import * as NodeNet from "node:net"; +import * as NodeCrypto from "node:crypto"; +import { + Root, + decodeEventStream, + encodeEventStream, + isVisible, + type StreamEvent, +} from "@clo/lib/progress"; +import { + ScriptProgressStreamError, + type ScriptProgressEvent, + type ScriptProgressStream, + type ThreadId, +} from "@t3tools/contracts"; +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; +import { progressPreloadSource } from "@t3tools/shared/progressPreload"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../config.ts"; + +export class ScriptProgressSetupError extends Schema.TaggedError()( + "ScriptProgressSetupError", + { operation: Schema.Literals(["preload", "listen"]), cause: Schema.Defect() }, +) { + override get message(): string { + return "Unable to prepare script progress capture."; + } +} + +const Hello = Schema.Struct({ + type: Schema.Literal("hello"), + token: Schema.String.check(Schema.isMaxLength(128)), + pid: Schema.Int.check(Schema.isGreaterThan(0)), + producerId: Schema.String.check(Schema.isMinLength(1)).check(Schema.isMaxLength(128)), + label: Schema.optional(Schema.String.check(Schema.isMaxLength(256))), +}); +const Progress = Schema.Struct({ + type: Schema.Literal("progress"), + streamId: Schema.String.check(Schema.isMaxLength(128)), + event: Schema.Array(Schema.Json).check(Schema.isMaxLength(4096)), +}); +const decodeHello = Schema.decodeUnknownSync(Hello); +const decodeProgress = Schema.decodeUnknownSync(Progress); +const decodePacket = Schema.decodeSync(Schema.fromJsonString(Schema.Array(Schema.Json))); +const encodePreloadPath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); + +type Capture = { + readonly id: string; + readonly threadId: ThreadId; + readonly pid: number; + readonly producerId: string; + readonly streamId: string; + readonly socket: NodeNet.Socket; + readonly label: string; + readonly root: Root; + readonly input: ReadableStreamDefaultController; + readonly decoder: ReturnType>; + status: "connected" | "disconnected"; +}; + +type Subscriber = { + readonly threadId: ThreadId; + readonly send: (event: ScriptProgressEvent) => boolean; + readonly attach: (capture: Capture) => void; + readonly detach: (id: string) => void; +}; + +const describe = (capture: Capture): ScriptProgressStream => ({ + id: capture.id, + pid: capture.pid, + label: capture.label, + status: capture.status, + summary: capture.root.active.filter(isVisible).map((node) => ({ + text: node.text, + value: node.value, + total: node.total, + detail: node.showTotal ? node.valueFormatter(node.value, node.total || null) : "", + })), +}); + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const runtimeDirectory = path.join(config.stateDir, "runtime"); + const preloadPath = path.join(runtimeDirectory, "script-progress-preload.cjs"); + yield* fileSystem.makeDirectory(runtimeDirectory, { recursive: true }).pipe( + Effect.andThen(fileSystem.writeFileString(preloadPath, progressPreloadSource, { mode: 0o600 })), + Effect.mapError((cause) => new ScriptProgressSetupError({ operation: "preload", cause })), + ); + + const tokens = new Map(); + const tokenThreads = new Map(); + const captures = new Map(); + const subscribers = new Set(); + const sockets = new Set(); + let closed = false; + + const forget = (capture: Capture) => { + captures.delete(capture.id); + for (const subscriber of subscribers) { + if (subscriber.threadId !== capture.threadId) continue; + subscriber.detach(capture.id); + subscriber.send({ type: "remove", id: capture.id }); + } + capture.decoder.cancel(); + }; + + const server = NodeNet.createServer((socket) => { + if (closed || sockets.size >= 256) { + socket.destroy(); + return; + } + sockets.add(socket); + socket.setEncoding("utf8"); + socket.setTimeout(10_000); + let pending = ""; + let hello: typeof Hello.Type | undefined; + let threadId: ThreadId | undefined; + let pendingPackets = 0; + const roots = new Map(); + socket.on("error", () => socket.destroy()); + socket.on("timeout", () => socket.destroy()); + socket.on("close", () => { + sockets.delete(socket); + if (closed) return; + for (const capture of roots.values()) { + if (!captures.has(capture.id)) continue; + capture.status = "disconnected"; + captures.delete(capture.id); + captures.set(capture.id, capture); + for (const subscriber of subscribers) { + if (subscriber.threadId === capture.threadId) { + subscriber.send({ type: "upsert", stream: describe(capture) }); + } + } + } + const disconnected = [...captures.values()].filter( + (capture) => capture.threadId === threadId && capture.status === "disconnected", + ); + for (const capture of disconnected.slice(0, -16)) forget(capture); + }); + socket.on("data", (chunk: string) => { + if (socket.destroyed) return; + pending += chunk; + if (Buffer.byteLength(pending) > 1024 * 1024) { + socket.destroy(); + return; + } + try { + let newline: number; + while ((newline = pending.indexOf("\n")) !== -1) { + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + const message: unknown = JSON.parse(line); + if (!hello) { + hello = decodeHello(message); + threadId = tokenThreads.get(hello.token); + if (!threadId) throw new Error("Unknown progress token"); + socket.setTimeout(0); + continue; + } + const packet = decodeProgress(message); + if (!threadId || ++pendingPackets > 256) throw new Error("Progress input overflow"); + let capture = roots.get(packet.streamId); + if (!capture) { + if (roots.size >= 16) throw new Error("Too many progress roots"); + for (const previous of captures.values()) { + if ( + previous.threadId !== threadId || + previous.producerId !== hello.producerId || + previous.streamId !== packet.streamId + ) + continue; + forget(previous); + previous.socket.destroy(); + } + const threadCaptures = [...captures.values()].filter( + (item) => item.threadId === threadId, + ); + if (threadCaptures.filter((item) => item.status === "connected").length >= 64) { + throw new Error("Too many active progress roots"); + } + if (captures.size >= 512) { + const oldest = [...captures.values()].find((item) => item.status === "disconnected"); + if (!oldest) throw new Error("Too many progress captures"); + forget(oldest); + } + const root = new Root(); + let input!: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(controller) { + input = controller; + }, + pull() { + pendingPackets = Math.max(0, pendingPackets - 1); + }, + }); + const decoder = decodeEventStream(source, root); + capture = { + id: NodeCrypto.randomUUID(), + threadId, + pid: hello.pid, + producerId: hello.producerId, + streamId: packet.streamId, + socket, + label: hello.label ?? "node", + root, + input, + decoder, + status: "connected", + }; + const activeCapture = capture; + const checkRetainedState = () => { + const stack = [...root.active]; + let count = 0; + let bytes = 0; + while (stack.length > 0) { + const node = stack.pop(); + if (!node) break; + bytes += + Buffer.byteLength(node.text) + + Buffer.byteLength(JSON.stringify(node.logs)) + + Buffer.byteLength(node.valueFormatter(node.value, node.total || null)); + if (++count > 4096 || bytes > 1024 * 1024) { + forget(activeCapture); + roots.delete(packet.streamId); + socket.destroy(); + return; + } + stack.push(...node.children); + } + }; + root.on("change", checkRetainedState); + void decoder + .then(() => { + roots.delete(packet.streamId); + forget(activeCapture); + }) + .catch(() => { + if (activeCapture.status === "connected") socket.destroy(); + }); + roots.set(packet.streamId, capture); + captures.set(capture.id, capture); + for (const subscriber of subscribers) { + if (subscriber.threadId !== threadId) continue; + subscriber.send({ type: "upsert", stream: describe(capture) }); + subscriber.attach(capture); + } + } + // The library decoder owns protocol validation and state transitions. + capture.input.enqueue(packet.event as StreamEvent); + } + } catch { + socket.destroy(); + } + }); + }); + + const address = yield* Effect.acquireRelease( + Effect.callback((resume) => { + const onError = (cause: Error) => + resume(Effect.fail(new ScriptProgressSetupError({ operation: "listen", cause }))); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + const address = server.address(); + if (address && typeof address !== "string") resume(Effect.succeed(address)); + }); + return Effect.sync(() => server.close()); + }), + () => + Effect.sync(() => { + closed = true; + for (const socket of sockets) socket.destroy(); + for (const capture of captures.values()) capture.decoder.cancel(); + tokens.clear(); + tokenThreads.clear(); + server.close(); + }), + ); + + return ScriptProgress.ScriptProgress.of({ + environmentForThread: (threadId) => + Effect.sync(() => { + let token = tokens.get(threadId); + if (!token) { + token = NodeCrypto.randomBytes(32).toString("hex"); + tokens.set(threadId, token); + tokenThreads.set(token, threadId); + } + return { + T3_SCRIPT_PROGRESS_ENDPOINT: `127.0.0.1:${address.port}`, + T3_SCRIPT_PROGRESS_TOKEN: token, + NODE_OPTIONS: `--require=${encodePreloadPath(preloadPath)}`, + }; + }), + subscribe: (threadId) => + Stream.callback( + (queue) => + Effect.acquireRelease( + Effect.sync(() => { + const readers = new Map>(); + let disposed = false; + const send = (event: ScriptProgressEvent) => { + if (disposed) return false; + if (Queue.offerUnsafe(queue, event)) return true; + Queue.failCauseUnsafe(queue, Cause.fail(new ScriptProgressStreamError({}))); + return false; + }; + const subscriber: Subscriber = { + threadId, + send, + attach: (capture) => { + const reader = encodeEventStream(capture.root, { throttleMs: 100 }).getReader(); + readers.set(capture.id, reader); + void (async () => { + try { + while (readers.get(capture.id) === reader) { + const result = await reader.read(); + if (result.done) break; + if ( + !send({ + type: "progress", + id: capture.id, + event: decodePacket(JSON.stringify(result.value)), + summary: describe(capture).summary, + }) + ) + break; + } + } catch { + // Disconnect is represented by the descriptor, never success. + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } + })(); + }, + detach: (id) => { + const reader = readers.get(id); + readers.delete(id); + void reader?.cancel().catch(() => {}); + }, + }; + subscribers.add(subscriber); + const existing = [...captures.values()].filter( + (capture) => capture.threadId === threadId, + ); + send({ type: "snapshot", streams: existing.map(describe) }); + for (const capture of existing) subscriber.attach(capture); + return () => { + disposed = true; + subscribers.delete(subscriber); + for (const id of readers.keys()) subscriber.detach(id); + }; + }), + (dispose) => Effect.sync(dispose), + ), + { bufferSize: 256 }, + ), + }); +}); + +export const layer = Layer.effect(ScriptProgress.ScriptProgress, make); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index f4256c9fedd87134d4f86cc5b483030473d7845c..227dca710c122696bd376e90bca86a1a8a48f977 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { @@ -267,7 +268,7 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { ), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(IdAllocator.layer), - Layer.provideMerge(McpProviderSessions.layer), + Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), ); const layerTest = ProviderHostLive.layer.pipe( Layer.provideMerge(ServerSecretStore.layer), diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 5317849cfa477bef387b9f940491112f6f09c566..35df28d04a426efbb418bb17d49a545ab2aa90ae 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import { withAgentDeviceEnvironment } from "@t3tools/provider-core/server/mcpSession"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; @@ -78,6 +79,7 @@ export type AntigravityDriverEnv = | FileSystem.FileSystem | HttpClient.HttpClient | IdAllocator.IdAllocatorV2 + | ScriptProgress.ScriptProgress | McpProviderSessions.McpProviderSessions | ModelCatalog.ModelCatalog | Path.Path @@ -249,7 +251,10 @@ export const AntigravityDriver: ProviderDriver< installation: executable, profile, cwd: input.cwd, - baseEnv: withAgentDeviceEnvironment(processEnvironment, input), + baseEnv: ScriptProgress.withScriptProgressEnvironment( + withAgentDeviceEnvironment(processEnvironment, input), + input.processEnvironment ?? {}, + ), auth, runtimeTempDirectory, }), diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 7ac25a2e6bc13da26034dc3ee7709c8f11bdc0e0..8a5f4bea92f5ffbc092b7dc821829501986058e5 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as CodexInstallation from "../CodexInstallation.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; @@ -46,7 +47,7 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { }).pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(IdAllocator.layer), - Layer.provideMerge(McpProviderSessions.layer), + Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), Layer.provideMerge( Layer.mock(CodexAdapterV2.CodexAppServerClientFactory)({ open: () => Effect.die("Maintenance resolution must not open a Codex session"), diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index eb3dc4109b851de3298e1f3c258b03203d87b280..7ba4e9b41d59fb9bc82a4f26b16d7d6eaada2915 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -233,7 +234,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { ), ), Layer.provideMerge(ProviderLatestVersions.layer), - Layer.provideMerge(McpProviderSessions.layer), + Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), ); @@ -652,7 +653,7 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { ), ), Layer.provideMerge(ProviderLatestVersions.layer), - Layer.provideMerge(McpProviderSessions.layer), + Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), ); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index d0c1f348a7366ff4db478b2987b28f71c77b5184..22afab7e181ce0ad4aa451fbed6ce8c67caf9c51 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -2762,7 +2763,9 @@ it.layer( ), ), Layer.provideMerge(ProviderLatestVersions.layer), - Layer.provideMerge(McpProviderSessions.layer), + Layer.provideMerge( + Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), + ), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge( @@ -2867,7 +2870,9 @@ it.layer( ), ), Layer.provideMerge(ProviderLatestVersions.layer), - Layer.provideMerge(McpProviderSessions.layer), + Layer.provideMerge( + Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), + ), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge( @@ -2987,7 +2992,9 @@ it.layer( ), ), Layer.provideMerge(ProviderLatestVersions.layer), - Layer.provideMerge(McpProviderSessions.layer), + Layer.provideMerge( + Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), + ), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge( @@ -3047,7 +3054,9 @@ it.layer( ), ), Layer.provideMerge(ProviderLatestVersions.layer), - Layer.provideMerge(McpProviderSessions.layer), + Layer.provideMerge( + Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), + ), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge( diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index d03ce73d09e225b5ba65681fb78de40b656963b8..55f85c026a070b206d7233295e56b3a4e75945ab 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -36,6 +36,7 @@ export interface AntigravityAcpRuntimeInput extends Omit< | "transformSessionUpdate" | "transformStdout" > { + readonly processEnvironment?: NodeJS.ProcessEnv; /** Device CLI environment supplied for this provider session. */ readonly agentDeviceEnvironment?: Readonly>; readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 20a3f603ff9a190f6111f58aa26e2e2a9cbbc418..3a11747c340f6a9a24fca502bb9432bc8b63c4a4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "./progress/ScriptProgress.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; import * as Clock from "effect/Clock"; @@ -1116,6 +1117,7 @@ const layerMakeServer = Layer.unwrap( Layer.provide(VcsProcess.layer), // Every agent and terminal spawn reads this, so it sits below everything. Layer.provideMerge(AgentScopeLive.layer), + Layer.provideMerge(ScriptProgress.layer), Layer.provideMerge(layerPlatformServices), ); }), diff --git a/apps/server/src/terminal/Manager.progress.test.ts b/apps/server/src/terminal/Manager.progress.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..645342f50ebdc503a2614de57a770b7a977994e6 --- /dev/null +++ b/apps/server/src/terminal/Manager.progress.test.ts @@ -0,0 +1,114 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { DEFAULT_TERMINAL_ID, ThreadId } from "@t3tools/contracts"; +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; +import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import * as ProcessRunner from "../processRunner.ts"; +import * as Manager from "./Manager.ts"; +import * as NodePtyAdapter from "./NodePtyAdapter.ts"; + +it.live( + "injects progress after terminal filtering and keeps concurrent shells and their children isolated", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-progress-terminal-" }); + const preload = path.join(directory, "progress preload.mjs"); + const userPreload = path.join(directory, "user.mjs"); + const script = path.join(directory, "probe.mjs"); + yield* fs.writeFileString(preload, "globalThis.progressPreloaded = true;"); + yield* fs.writeFileString(userPreload, "globalThis.userPreloaded = true;"); + yield* fs.writeFileString( + script, + ` + import { execFileSync } from 'node:child_process'; + const child = execFileSync(process.execPath, ['--input-type=module', '-e', + 'console.log(JSON.stringify({ token: process.env.T3_SCRIPT_PROGRESS_TOKEN, nativeThread: process.env.CODEX_THREAD_ID, progress: globalThis.progressPreloaded, user: globalThis.userPreloaded }))' + ], { encoding: 'utf8' }); + console.log('PROGRESS_RESULT:' + child.trim()); + `, + ); + const originalOptions = process.env.NODE_OPTIONS; + const completed = new Map([ + ["progress-terminal-one", yield* Deferred.make()], + ["progress-terminal-two", yield* Deferred.make()], + ]); + const output = new Map(); + const manager = yield* Manager.makeWithOptions({ + logsDir: path.join(directory, "logs"), + ptyAdapter: yield* PtyAdapter.PtyAdapter, + shellResolver: () => "/bin/sh", + env: { ...process.env, NODE_OPTIONS: `--import=${JSON.stringify(userPreload)}` }, + subprocessInspector: () => + Effect.succeed({ hasRunningSubprocess: false, childCommand: null, processIds: [] }), + }).pipe( + Effect.provideService(ScriptProgress.ScriptProgress, { + environmentForThread: (threadId) => + Effect.succeed({ + NODE_OPTIONS: `--import=${JSON.stringify(preload)}`, + T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1/progress", + T3_SCRIPT_PROGRESS_TOKEN: threadId, + }), + subscribe: () => Stream.empty, + }), + ); + const unsubscribe = yield* manager.subscribe((event) => { + if (event.type !== "output") return Effect.void; + const data = (output.get(event.threadId) ?? "") + event.data; + output.set(event.threadId, data); + const result = /PROGRESS_RESULT:([^\r\n]+)[\r\n]/.exec(data); + const done = completed.get(event.threadId); + return result && done + ? Deferred.succeed(done, result[1]!).pipe(Effect.asVoid) + : Effect.void; + }); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + yield* Effect.all( + [...completed].map(([threadId, done]) => + Effect.gen(function* () { + yield* manager.open({ + threadId, + terminalId: DEFAULT_TERMINAL_ID, + cwd: directory, + cols: 120, + rows: 24, + env: { CODEX_THREAD_ID: "native-child" }, + }); + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + yield* manager.write({ + threadId, + terminalId: DEFAULT_TERMINAL_ID, + data: `${quote(process.execPath)} ${quote(script)}\n`, + }); + const result = JSON.parse(yield* Deferred.await(done)); + expect(result).toEqual({ + token: ThreadId.make(threadId), + nativeThread: "native-child", + progress: true, + user: true, + }); + yield* manager.close({ threadId }); + }), + ), + { concurrency: "unbounded" }, + ); + expect(process.env.NODE_OPTIONS).toBe(originalOptions); + }).pipe( + Effect.timeout("15 seconds"), + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodePtyAdapter.layer.pipe(Layer.provide(NodeServices.layer)), + ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)), + ), + ), + ), +); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index d8639c8015bc572b029dba1bd3d31b12fdae4e1f..77dd6cd082dd069131f3b662f7b17c4918067911 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { @@ -297,7 +298,7 @@ const createManager = ( managedBinaryCacheDir: options.managedBinaryCacheDir, managedBinaryToolsDir: options.managedBinaryToolsDir, }), - }); + }).pipe(Effect.provide(ScriptProgress.layerUnavailable)); const eventsRef = yield* Ref.make>([]); const unsubscribe = yield* manager.subscribe((event) => Ref.update(eventsRef, (events) => [...events, event]), diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 4f79d07ff40f9fc832c90ad0f18bce1ca004aa9e..722f8d6238371d99ba48720ef4065b07143b0efe 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; /** * TerminalManager - Terminal session orchestration service interface. * @@ -9,6 +10,7 @@ import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { DEFAULT_TERMINAL_ID, + ThreadId, TerminalCwdError, TerminalCwdNotDirectoryError, TerminalCwdNotFoundError, @@ -1491,6 +1493,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const platform = yield* HostProcess.Platform; const architecture = yield* HostProcess.Architecture; const agentScope = yield* AgentScope; + const scriptProgress = yield* ScriptProgress.ScriptProgress; // Terminals must inherit the user's full environment (minus the blocklist // applied in createTerminalSpawnEnv) — an allowlist here silently strips // things like PSModulePath, DISPLAY, proxies, and toolchain variables. @@ -2263,11 +2266,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.andThen( Effect.gen(function* () { const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); - const terminalEnv = createTerminalSpawnEnv( - baseEnv, - session.runtimeEnv, - platform, - yield* HostProcess.HomeDirectory, + const terminalEnv = ScriptProgress.withScriptProgressEnvironment( + createTerminalSpawnEnv( + baseEnv, + session.runtimeEnv, + platform, + yield* HostProcess.HomeDirectory, + ), + yield* scriptProgress.environmentForThread(ThreadId.make(session.threadId)), ); // Append (never prepend) managed ACP agent install directories so // `kimi login` and friends resolve by name without shadowing any diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index dece5c9706638e7464870f769ccffcdd165dca9f..fd91a717a70c4b554a32eb2ae1f13064907a1c55 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,4 @@ +import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; @@ -1239,6 +1240,7 @@ const layerWsRpc = ( const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; const secretRequests = yield* SecretRequests.SecretRequests; + const scriptProgress = yield* ScriptProgress.ScriptProgress; const pullRequests = yield* PullRequestService.PullRequestService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; @@ -2902,6 +2904,7 @@ const layerWsRpc = ( [WS_METHODS.reviewGetDiffPreview]: (input) => review.getDiffPreview(input), [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input), ...makeTerminalRpcHandlers(terminalManager), + [WS_METHODS.subscribeScriptProgress]: (input) => scriptProgress.subscribe(input.threadId), [WS_METHODS.subscribeTerminalMetadata]: (_input) => Stream.callback((queue) => Effect.acquireRelease( diff --git a/apps/web/package.json b/apps/web/package.json index a2e532a200927b0fbfdc6ec0453b94d28d454751..7100499fda7670850f9395d35af9355c8fadeea6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "@base-ui/react": "^1.4.1", "@clerk/electron": "catalog:", "@clerk/react": "catalog:", + "@clo/lib": "workspace:*", "@clo/markdown-editor": "workspace:*", "@clo/terminal": "workspace:*", "@codemirror/commands": "^6.8.1", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 84b44a2788bff32d3109da9e514173e02caf06a9..76ec21e6040b535fa942091af4b635213592ae68 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -275,6 +275,7 @@ import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { BrowserAudioIndicator } from "./preview/BrowserAudioIndicator"; +import { ScriptProgress } from "./chat/ScriptProgress"; import { usePreviewSession } from "./preview/usePreviewSession"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; @@ -11751,6 +11752,12 @@ export default function ChatView(props: ChatViewProps) { className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-(--chat-content-max-width)" > + {!paintOnlyDisplayedTimeline ? ( + + ) : null} {isDraftHeroState ? (
; + streams: ReadonlyArray; +}>; + +const state = vi.hoisted(() => ({ atom: undefined as unknown })); +vi.mock("../../state/scriptProgress", () => ({ + scriptProgressEnvironment: { watch: () => state.atom }, +})); + +let root: Root; +let container: HTMLDivElement; +let atom: Atom.Writable; +const descriptor: ScriptProgressStream = { + id: "script", + pid: 42, + label: "render.ts", + status: "connected", + summary: [{ text: "Rendering", value: 4, total: 10, detail: "4/10" }], +}; + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const initial: ProgressResult = AsyncResult.success({ + events: [{ type: "snapshot", streams: [] }], + streams: [], + }); + atom = Atom.make(initial); + state.atom = atom; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root.render( + , + ); + }); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +async function emit(event: ScriptProgressEvent) { + await act(async () => { + appAtomRegistry.set(atom, AsyncResult.success({ events: [event], streams: [] })); + }); +} + +function progressbar() { + const tree = container.querySelector("[data-progress-motion]"); + return tree?.shadowRoot?.querySelector('[role="progressbar"]'); +} + +it("renders actual progress values and resets to zero without resetting the tree", async () => { + expect(container.querySelector("section")?.hidden).toBe(true); + await emit({ type: "snapshot", streams: [descriptor] }); + await emit({ + type: "progress", + id: descriptor.id, + event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]], + summary: descriptor.summary, + }); + await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4")); + expect(progressbar()?.getAttribute("aria-valuemax")).toBe("10"); + expect(container.querySelector("section")?.hidden).toBe(false); + + await emit({ + type: "progress", + id: descriptor.id, + event: [0, { k: 1, v: 0 }], + summary: [{ text: "Rendering", value: 0, total: 10, detail: "0/10" }], + }); + await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("0")); +}); + +it("removes the old chat's decoder and DOM before displaying another chat", async () => { + await emit({ type: "snapshot", streams: [descriptor] }); + await emit({ + type: "progress", + id: descriptor.id, + event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]], + summary: descriptor.summary, + }); + await vi.waitFor(() => expect(progressbar()).toBeTruthy()); + await act(async () => { + root.render( + , + ); + }); + expect(progressbar()).toBeUndefined(); + expect(container.querySelector("section")?.hidden).toBe(true); +}); + +it("requests a fresh tree when returning to a chat whose live atom cached a delta", async () => { + let connections = 0; + const snapshot: ScriptProgressEvent = { type: "snapshot", streams: [descriptor] }; + const update: ScriptProgressEvent = { + type: "progress", + id: descriptor.id, + event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]], + summary: descriptor.summary, + }; + state.atom = Atom.make( + Stream.suspend(() => { + connections++; + return Stream.concat( + Stream.fromArray([{ events: [snapshot, update], streams: [descriptor] }]), + Stream.never, + ); + }), + ).pipe(Atom.keepAlive); + + await act(async () => { + root.render( + , + ); + }); + await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4")); + const initialConnections = connections; + await act(async () => root.unmount()); + root = createRoot(container); + await act(async () => { + root.render( + , + ); + }); + + await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4")); + expect(connections).toBeGreaterThan(initialConnections); +}); + +it("hides the panel when a passive grouping node has no visible tasks left", async () => { + await emit({ type: "snapshot", streams: [descriptor] }); + await emit({ + type: "progress", + id: descriptor.id, + event: [-1, { k: 1, t: "Group", p: true, c: [2] }, { k: 2, t: "Compiling", v: 1, e: 4 }, [1]], + summary: descriptor.summary, + }); + await vi.waitFor(() => expect(container.querySelector("section")?.hidden).toBe(false)); + await emit({ + type: "progress", + id: descriptor.id, + event: [1, 2, { k: 1, c: [] }], + summary: [], + }); + await vi.waitFor(() => expect(container.querySelector("section")?.hidden).toBe(true)); +}); + +it("hides an empty disconnected script while another script is running", async () => { + const empty = { ...descriptor, id: "empty", status: "disconnected" as const, summary: [] }; + await emit({ type: "snapshot", streams: [empty, descriptor] }); + await emit({ type: "progress", id: empty.id, event: [-1, []], summary: [] }); + await emit({ + type: "progress", + id: descriptor.id, + event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]], + summary: descriptor.summary, + }); + + const section = container.querySelector("section"); + await vi.waitFor(() => expect(section?.hidden).toBe(false)); + expect(section?.children[0]?.getAttribute("hidden")).toBe(""); + expect(section?.children[1]?.hasAttribute("hidden")).toBe(false); +}); diff --git a/apps/web/src/components/chat/ScriptProgress.tsx b/apps/web/src/components/chat/ScriptProgress.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3c8e60e42a0ec766b0d429bea5c84240665a39c8 --- /dev/null +++ b/apps/web/src/components/chat/ScriptProgress.tsx @@ -0,0 +1,162 @@ +import * as progress from "@clo/lib/progress"; +import { attachToDocument } from "@clo/lib/progress/browser"; +import type { + EnvironmentId, + ScriptProgressEvent, + ScriptProgressStream, + ThreadId, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; +import { useEffect, useRef } from "react"; + +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { scriptProgressEnvironment } from "../../state/scriptProgress"; + +export function ScriptProgress({ + environmentId, + threadId, +}: { + environmentId: EnvironmentId; + threadId: ThreadId; +}) { + const container = useRef(null); + + useEffect(() => { + const host = container.current; + if (!host) return; + + const streams = new Map< + string, + { + root: progress.Root; + input: ReadableStreamDefaultController; + element: HTMLElement; + status: HTMLElement; + dispose: () => void; + } + >(); + + const updateVisibility = () => { + let visible = false; + for (const stream of streams.values()) { + stream.element.hidden = !stream.root.active.some(progress.isVisible); + visible ||= !stream.element.hidden; + } + host.hidden = !visible; + }; + + const remove = (id: string) => { + const stream = streams.get(id); + if (!stream) return; + streams.delete(id); + stream.dispose(); + stream.element.remove(); + updateVisibility(); + }; + + const upsert = (descriptor: ScriptProgressStream) => { + let stream = streams.get(descriptor.id); + if (!stream) { + const root = new progress.Root(); + const element = document.createElement("div"); + element.hidden = true; + const status = document.createElement("div"); + status.className = "text-xs text-muted-foreground"; + const tree = document.createElement("div"); + tree.dataset.progressMotion = "static"; + element.append(status, tree); + host.append(element); + + const detach = attachToDocument(root, tree); + const off = root.on("change", updateVisibility); + let input: ReadableStreamDefaultController | undefined; + const readable = new ReadableStream({ + start(controller) { + input = controller; + }, + }); + if (!input) throw new Error("Progress stream did not initialize."); + + const decoder = progress.decodeEventStream(readable, root); + void decoder.catch(() => { + status.textContent = "Progress unavailable"; + status.hidden = false; + updateVisibility(); + }); + stream = { + root, + input, + element, + status, + dispose: () => { + off(); + decoder.cancel(); + detach(); + }, + }; + streams.set(descriptor.id, stream); + } + + stream.status.textContent = + descriptor.status === "disconnected" ? `${descriptor.label} · Disconnected` : ""; + stream.status.hidden = descriptor.status === "connected"; + return stream; + }; + + const subscription = scriptProgressEnvironment.watch({ + environmentId, + input: { threadId }, + }); + appAtomRegistry.refresh(subscription); + let previousEvents: ReadonlyArray | undefined; + const unsubscribe = appAtomRegistry.subscribe( + subscription, + (result) => { + if (!AsyncResult.isSuccess(result)) { + previousEvents = undefined; + for (const id of streams.keys()) remove(id); + return; + } + + const { events } = result.value; + if (events === previousEvents) return; + previousEvents = events; + for (const event of events) { + switch (event.type) { + case "snapshot": + for (const id of streams.keys()) remove(id); + for (const descriptor of event.streams) upsert(descriptor); + break; + case "upsert": + upsert(event.stream); + break; + case "progress": { + const stream = streams.get(event.id); + // The shared decoder owns the stable wire format; RPC validates JSON at its boundary. + stream?.input.enqueue(event.event as progress.StreamEvent); + break; + } + case "remove": + remove(event.id); + break; + } + } + }, + { immediate: true }, + ); + + return () => { + unsubscribe(); + for (const id of streams.keys()) remove(id); + }; + }, [environmentId, threadId]); + + return ( +