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 ( +