diff --git a/apps/coder-gateway/package.json b/apps/coder-gateway/package.json index 4a0259da4f08c..7df85443d23a8 100644 --- a/apps/coder-gateway/package.json +++ b/apps/coder-gateway/package.json @@ -5,7 +5,7 @@ "scripts": { "start": "node src/bin.ts", "test": "node --test src/*.test.ts", - "typecheck": "tsc --noEmit" + "typecheck": "effect-tsgo tsrs --noEmit" }, "dependencies": { "@effect/platform-node": "catalog:", diff --git a/apps/coder-gateway/src/assetProxy.test.ts b/apps/coder-gateway/src/assetProxy.test.ts index 304b4ee12aba8..0911b6f21d268 100644 --- a/apps/coder-gateway/src/assetProxy.test.ts +++ b/apps/coder-gateway/src/assetProxy.test.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Coder: drives the real loopback server with Node HTTP and fetch, as a browser would. +// @effect-diagnostics globalFetch:off -- Coder: drives the real loopback server with fetch, as a browser would. +// @effect-diagnostics globalDate:off -- Coder: polls the real server against wall-clock deadlines. +// @effect-diagnostics globalTimers:off -- Coder: polls the real server against wall-clock deadlines. import assert from "node:assert/strict"; import * as NodeHttp from "node:http"; import type { AddressInfo } from "node:net"; diff --git a/apps/coder-gateway/src/assetProxy.ts b/apps/coder-gateway/src/assetProxy.ts index e28cb3ffb7be2..6a9bf46c118f1 100644 --- a/apps/coder-gateway/src/assetProxy.ts +++ b/apps/coder-gateway/src/assetProxy.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Coder: streams workspace assets through the gateway's Node loopback HTTP server. import type * as NodeHttp from "node:http"; import type { CoderHelperConnection } from "@t3tools/coder-cli/helperConnection"; diff --git a/apps/coder-gateway/src/bin.ts b/apps/coder-gateway/src/bin.ts index 11be57162466b..ce7ccf08d7874 100644 --- a/apps/coder-gateway/src/bin.ts +++ b/apps/coder-gateway/src/bin.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Coder: the CLI entry point reads its static files with Node fs. +// @effect-diagnostics anyUnknownInErrorContext:off -- Coder: the entry point runs the gateway whose route errors stay untyped (rendered by message). import { readFile } from "node:fs/promises"; // @effect-diagnostics nodeBuiltinImport:off import { spawn } from "node:child_process"; diff --git a/apps/coder-gateway/src/coderCommands.ts b/apps/coder-gateway/src/coderCommands.ts index f749ce3382f97..4442a20b6ff1e 100644 --- a/apps/coder-gateway/src/coderCommands.ts +++ b/apps/coder-gateway/src/coderCommands.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics globalDate:off -- Coder: formats Coder's build deadline from `coder list` JSON synchronously. +// @effect-diagnostics globalErrorInEffectFailure:off -- Coder: a cancelled connection fails with the message the settings UI shows. +// @effect-diagnostics globalJsonInEffect:off -- Coder: parses `coder` CLI JSON output; the caller treats a parse failure as unavailable. import type { DiscoveredCoderWorkspace } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -55,7 +58,7 @@ export function checkCoderAuthStatus( ? "unauthenticated" : "unavailable"; }), - Effect.catch(() => Effect.succeed("unavailable" as const)), + Effect.orElseSucceed(() => "unavailable" as const), ); } diff --git a/apps/coder-gateway/src/configChanges.ts b/apps/coder-gateway/src/configChanges.ts index 111c14c282e2f..010cce90e8b7d 100644 --- a/apps/coder-gateway/src/configChanges.ts +++ b/apps/coder-gateway/src/configChanges.ts @@ -1,5 +1,6 @@ import type { CoderProfileConfig } from "@t3tools/coder-cli/configStore"; +// @effect-diagnostics-next-line extendsNativeError:off -- Coder: native error the gateway matches with instanceof and renders by message at the HTTP boundary. export class CoderConfigConflictError extends Error {} /** diff --git a/apps/coder-gateway/src/connectionShutdown.test.ts b/apps/coder-gateway/src/connectionShutdown.test.ts index 23467bbbf1a49..d4e2796cf5669 100644 --- a/apps/coder-gateway/src/connectionShutdown.test.ts +++ b/apps/coder-gateway/src/connectionShutdown.test.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Coder: spawns and drives the real gateway over Node HTTP. +// @effect-diagnostics globalFetch:off -- Coder: drives the real gateway over loopback HTTP, as a browser would. +// @effect-diagnostics anyUnknownInErrorContext:off -- Coder: test helpers run untyped gateway effects. import { it } from "node:test"; import { channel } from "node:diagnostics_channel"; import type { IncomingMessage } from "node:http"; diff --git a/apps/coder-gateway/src/hub.test.ts b/apps/coder-gateway/src/hub.test.ts index f192ed56e32c3..426cc578d1f47 100644 --- a/apps/coder-gateway/src/hub.test.ts +++ b/apps/coder-gateway/src/hub.test.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics globalDate:off -- Coder: waits in real time for fake helper stdio traffic. +// @effect-diagnostics globalTimers:off -- Coder: waits in real time for fake helper stdio traffic. import assert from "node:assert/strict"; import { test } from "node:test"; @@ -36,7 +38,7 @@ interface FakeHelper { function fakeHelper(environmentId: string, protocolVersion = 3): FakeHelper { const sent: Array = []; const listeners = new Set<(message: unknown) => void>(); - const closed = Effect.runSync(Deferred.make()); + const closed = Deferred.makeUnsafe(); const requests = (tag: string) => sent.filter((message) => message.tag === tag); const outboxId = () => { const outbox = requests(CODER_HUB_OUTBOX_METHOD)[0]; diff --git a/apps/coder-gateway/src/lifecycleState.ts b/apps/coder-gateway/src/lifecycleState.ts index 0dc68280e1d4b..4bbeeab88d995 100644 --- a/apps/coder-gateway/src/lifecycleState.ts +++ b/apps/coder-gateway/src/lifecycleState.ts @@ -11,6 +11,7 @@ import type { WorkspaceRpcBridge } from "./rpcBridge.ts"; export type RunPromise = (effect: Effect.Effect) => Promise; export type RunFork = (effect: Effect.Effect) => Fiber.Fiber; +// @effect-diagnostics-next-line extendsNativeError:off -- Coder: native error the gateway matches with instanceof and renders by message at the HTTP boundary. export class WorkspaceActionConflictError extends Error {} export interface DeferredPromise { diff --git a/apps/coder-gateway/src/portForwards.ts b/apps/coder-gateway/src/portForwards.ts index 39132cf8fa8f6..253e2b4e1fcbd 100644 --- a/apps/coder-gateway/src/portForwards.ts +++ b/apps/coder-gateway/src/portForwards.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics globalErrorInEffectFailure:off -- Coder: port-forward errors reach the settings UI by message. +// @effect-diagnostics anyUnknownInErrorContext:off -- Coder: coder port-forward process errors are untyped in coder-cli. import type { DiscoveredCoderWorkspace } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; diff --git a/apps/coder-gateway/src/process.ts b/apps/coder-gateway/src/process.ts index 2dab3887b1466..272de3cbc19bd 100644 --- a/apps/coder-gateway/src/process.ts +++ b/apps/coder-gateway/src/process.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"; import type { CoderInvocation } from "@t3tools/coder-cli/command"; import { terminateChildProcess } from "@t3tools/coder-cli/processTermination"; +// @effect-diagnostics-next-line extendsNativeError:off -- Coder: native error the gateway matches with instanceof and renders by message at the HTTP boundary. export class GatewayProcessError extends Error { readonly _tag = "GatewayProcessError"; @@ -73,10 +74,7 @@ const terminateGatewayProcess = ( child: process.child, exit: process.exited, graceMs: terminationGraceMs, - }).pipe( - Effect.catchCause(() => Effect.void), - Effect.ensuring(Effect.sync(process.cleanupListeners)), - ); + }).pipe(Effect.ignoreCause, Effect.ensuring(Effect.sync(process.cleanupListeners))); export function runGatewayProcess( invocation: CoderInvocation, diff --git a/apps/coder-gateway/src/rpcBridge.test.ts b/apps/coder-gateway/src/rpcBridge.test.ts index b031d6da05dfa..882b50efa3e3d 100644 --- a/apps/coder-gateway/src/rpcBridge.test.ts +++ b/apps/coder-gateway/src/rpcBridge.test.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics globalDate:off -- Coder: waits in real time for fake helper stdio traffic. +// @effect-diagnostics globalTimers:off -- Coder: waits in real time for fake helper stdio traffic. import assert from "node:assert/strict"; import { test } from "node:test"; diff --git a/apps/coder-gateway/src/rpcBridge.ts b/apps/coder-gateway/src/rpcBridge.ts index fa75cef02d8a2..c216053112717 100644 --- a/apps/coder-gateway/src/rpcBridge.ts +++ b/apps/coder-gateway/src/rpcBridge.ts @@ -36,6 +36,7 @@ export interface WorkspaceRpcBridgeOptions { readonly cleanupTimeout?: Duration.Input; } +// @effect-diagnostics-next-line extendsNativeError:off -- Coder: native error the gateway matches with instanceof and renders by message at the HTTP boundary. export class RpcBridgeConflictError extends Error { readonly _tag = "RpcBridgeConflictError"; @@ -45,6 +46,7 @@ export class RpcBridgeConflictError extends Error { } } +// @effect-diagnostics-next-line extendsNativeError:off -- Coder: native error the gateway matches with instanceof and renders by message at the HTTP boundary. export class RpcBridgeSessionError extends Error { readonly _tag = "RpcBridgeSessionError"; readonly kind: "protocol" | "helper"; @@ -179,7 +181,7 @@ export function makeWorkspaceRpcBridge( const runFork = yield* FiberSet.runtime(fibers)(); const cleanupTimeout = options?.cleanupTimeout ?? DEFAULT_CLEANUP_TIMEOUT; - const closeHelper = helper.close.pipe(Effect.catchCause(() => Effect.void)); + const closeHelper = helper.close.pipe(Effect.ignoreCause); const scheduleCleanupCheck = (sessionId: number): void => { runFork( diff --git a/apps/coder-gateway/src/server.test.ts b/apps/coder-gateway/src/server.test.ts index 6740f6be910cb..08e3287f78324 100644 --- a/apps/coder-gateway/src/server.test.ts +++ b/apps/coder-gateway/src/server.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics anyUnknownInErrorContext:off -- Coder: test helpers run untyped gateway effects. import { startLocalCoderGateway, type PromiseCoderHelperConnection as CoderHelperConnection, diff --git a/apps/coder-gateway/src/server.ts b/apps/coder-gateway/src/server.ts index 24ca3f3fa5cec..90e7bfd7ff704 100644 --- a/apps/coder-gateway/src/server.ts +++ b/apps/coder-gateway/src/server.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics unknownInEffectCatch:off -- Coder: route failures are rendered by message at the HTTP boundary. +// @effect-diagnostics anyUnknownInErrorContext:off -- Coder: route failures are rendered by message at the HTTP boundary. +// @effect-diagnostics globalErrorInEffectCatch:off -- Coder: route failures are rendered by message at the HTTP boundary. +// @effect-diagnostics globalErrorInEffectFailure:off -- Coder: route failures are rendered by message at the HTTP boundary. import type { DiscoveredCoderWorkspace } from "@t3tools/contracts"; export type { DiscoveredCoderWorkspace, @@ -179,7 +183,7 @@ export function makeLocalCoderGateway( const helperBundlePath = options?.helperBundlePath; const helperBundleHash = helperBundlePath === undefined - ? Effect.succeed(undefined) + ? Effect.undefined : yield* Effect.cached( Effect.tryPromise({ try: () => hashCoderHelperBundle(helperBundlePath), diff --git a/apps/coder-gateway/src/testUtils/gateway.ts b/apps/coder-gateway/src/testUtils/gateway.ts index 1533c5e0bed15..7bd7983e7ac20 100644 --- a/apps/coder-gateway/src/testUtils/gateway.ts +++ b/apps/coder-gateway/src/testUtils/gateway.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics unknownInEffectCatch:off -- Coder: test harness wraps the gateway's untyped effects. +// @effect-diagnostics anyUnknownInErrorContext:off -- Coder: test harness wraps the gateway's untyped effects. import { CoderHelperConnectionError } from "@t3tools/coder-cli/helperConnection"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; diff --git a/apps/coder-gateway/src/workspaceConnections.ts b/apps/coder-gateway/src/workspaceConnections.ts index d2f78e338ce5b..9c3e26dd0ba6f 100644 --- a/apps/coder-gateway/src/workspaceConnections.ts +++ b/apps/coder-gateway/src/workspaceConnections.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics globalDate:off -- Coder: diagnostic phase durations use wall-clock milliseconds. +// @effect-diagnostics globalErrorInEffectFailure:off -- Coder: connection errors reach the settings UI by message. +// @effect-diagnostics anyUnknownInErrorContext:off -- Coder: coder ssh and helper errors are untyped in coder-cli. +// @effect-diagnostics unknownInEffectCatch:off -- Coder: start-is-current checks rethrow the conflict error unchanged. import type { WorkspaceDiagnosticEvent, WorkspaceDiagnosticPhase } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; diff --git a/apps/coder-helper/package.json b/apps/coder-helper/package.json index 2bc3fcfe2b728..95751dc5ce282 100644 --- a/apps/coder-helper/package.json +++ b/apps/coder-helper/package.json @@ -6,12 +6,13 @@ "build": "node src/build.ts", "start": "node src/bin.ts", "test": "node --test src/*.test.ts", - "typecheck": "tsc --noEmit" + "typecheck": "effect-tsgo tsrs --noEmit" }, "dependencies": { "@effect/platform-node": "catalog:", "@t3tools/coder-cli": "workspace:*", "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", "effect": "catalog:", "t3": "workspace:*" }, diff --git a/apps/coder-helper/src/build.ts b/apps/coder-helper/src/build.ts index 2e0e8bc011d21..3d03bde03c886 100644 --- a/apps/coder-helper/src/build.ts +++ b/apps/coder-helper/src/build.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Coder: esbuild script that runs outside any Effect runtime. import { readBuildVersion } from "../../../scripts/build-info.ts"; import { build } from "esbuild"; import * as NodeFS from "node:fs/promises"; diff --git a/apps/coder-helper/src/bundle.test.ts b/apps/coder-helper/src/bundle.test.ts index ed21ff183ee47..d864ff1aa4c91 100644 --- a/apps/coder-helper/src/bundle.test.ts +++ b/apps/coder-helper/src/bundle.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalTimers:off -- Coder: waits in real time for the spawned helper bundle. import { deepStrictEqual, doesNotMatch, match, strictEqual } from "node:assert"; import { spawn } from "node:child_process"; import { once } from "node:events"; diff --git a/apps/coder-helper/src/helper.ts b/apps/coder-helper/src/helper.ts index 602a38ed236d1..bce9bee9fbddc 100644 --- a/apps/coder-helper/src/helper.ts +++ b/apps/coder-helper/src/helper.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeStdio from "@effect/platform-node/NodeStdio"; import { CoderWsRpcGroup } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -46,9 +47,10 @@ const coderServerConfigLayer = Layer.effect( ServerConfig.ServerConfig, Effect.gen(function* () { const path = yield* Path.Path; - const baseDir = - process.env.T3_CODER_HOME?.trim() || path.join(process.env.HOME ?? ".", ".t3-coder"); - const cwd = process.env.T3_CODER_CWD?.trim() || process.env.HOME?.trim() || process.cwd(); + const env = yield* HostProcess.Environment; + const baseDir = env.T3_CODER_HOME?.trim() || path.join(env.HOME ?? ".", ".t3-coder"); + const cwd = + env.T3_CODER_CWD?.trim() || env.HOME?.trim() || (yield* HostProcess.WorkingDirectory); const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir); yield* ServerConfig.ensureServerDirectories(derivedPaths); return ServerConfig.ServerConfig.of({ diff --git a/apps/server/package.json b/apps/server/package.json index 9a27e572b7b55..1acce3756ae58 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,10 +1,10 @@ { "name": "t3", - "version": "0.1.6", + "version": "0.1.7", "license": "MIT", "type": "module", "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "effect-tsgo tsrs --noEmit", "test": "vp test run" }, "dependencies": { @@ -29,7 +29,7 @@ "yauzl": "^3.4.0" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.276", + "@anthropic-ai/claude-agent-sdk": "0.3.316", "@anthropic-ai/sdk": "0.93.0", "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index ced86f3bbe215..d56192ae74593 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -43,6 +43,9 @@ const omitInitializeModelState = process.env.T3_ACP_OMIT_INITIALIZE_MODEL_STATE const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; const floodStderr = process.env.T3_ACP_FLOOD_STDERR === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; +const agentVersion = process.env.T3_ACP_AGENT_VERSION ?? "0.0.0"; +const geminiApiKey = process.env.GEMINI_API_KEY; +const idleError = process.env.T3_ACP_IDLE_ERROR; const emitTrackedToolCallKindThenHang = process.env.T3_ACP_EMIT_TRACKED_TOOL_CALL_KIND_THEN_HANG; const clientFsProbePath = process.env.T3_ACP_CLIENT_FS_PROBE_PATH; const clientFsProbeLogPath = process.env.T3_ACP_CLIENT_FS_PROBE_LOG_PATH; @@ -516,7 +519,7 @@ const program = Effect.gen(function* () { parameterizedModelPicker = request.capabilities?._meta?.parameterizedModelPicker === true; return { protocolVersion: 2, - info: { name: "t3-acp-mock-agent", version: process.env.T3_ACP_AGENT_VERSION ?? "0.0.0" }, + info: { name: "t3-acp-mock-agent", version: agentVersion }, ...(omitInitializeModelState ? {} : { _meta: { modelState: modelState() } }), capabilities: { session: { @@ -554,7 +557,7 @@ const program = Effect.gen(function* () { if (antigravityProfile) { if ( request.methodId !== "oauth-personal" && - !(request.methodId === "gemini-api-key" && process.env.GEMINI_API_KEY) + !(request.methodId === "gemini-api-key" && geminiApiKey) ) { return yield* AcpError.AcpRequestError.invalidParams( `Mock Antigravity rejected auth method ${request.methodId}.`, @@ -840,7 +843,6 @@ const program = Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); beginAcpMockPrompt(cancelledSessions, requestedSessionId); promptCount += 1; - const idleError = process.env.T3_ACP_IDLE_ERROR; if (idleError === "grok-background" && promptCount === 1) { // The structured completion lands before the prompt settles, so it is buffered. const taskId = "call-idle-error-background"; @@ -1877,6 +1879,11 @@ const program = Effect.gen(function* () { if (method === "_test/finish-cancel") { return Deferred.succeed(nativeCancelRelease, undefined).pipe(Effect.as({})); } + if (method === "_test/emit-update") { + return agent.client + .sessionUpdate(params as AcpSchema.SessionNotification) + .pipe(Effect.as({})); + } if (method === "_test/startup-metadata") { return Effect.gen(function* () { for (const [metadataSessionId, commandName, modeId] of [ diff --git a/apps/server/scripts/acp-replay-agent.ts b/apps/server/scripts/acp-replay-agent.ts index 950c6909d0133..c68366828e91e 100644 --- a/apps/server/scripts/acp-replay-agent.ts +++ b/apps/server/scripts/acp-replay-agent.ts @@ -50,7 +50,27 @@ const transcript = JSON.parse( ? Buffer.from(encodedTranscript ?? "", "base64").toString("utf8") : NodeFS.readFileSync(transcriptPath, "utf8"), ) as ReplayTranscript; -let cursor = 0; +/** + * A recording can span processes: a `runtime_exit` mid-transcript marks where + * one agent process ended, and a respawned replay agent continues from the + * cursor the previous one left in the status file. + */ +function resumedCursor(): number { + try { + const previous = JSON.parse(NodeFS.readFileSync(statusPath ?? "", "utf8")) as { + readonly cursor?: unknown; + readonly failure?: unknown; + }; + const resumeAt = typeof previous.cursor === "number" ? previous.cursor : 0; + return previous.failure === undefined && + transcript.entries[resumeAt - 1]?.type === "runtime_exit" + ? resumeAt + : 0; + } catch { + return 0; + } +} +let cursor = resumedCursor(); let stopped = false; let nextAgentRequestId = 1; const pendingClientRequestIds = new Map(); @@ -232,6 +252,8 @@ function flushInbound(): void { break; } cursor += 1; + // Frames after a mid-transcript exit belong to the next process. + if (cursor < transcript.entries.length) break; continue; } const frame = entry.frame as LogicalFrame; @@ -294,7 +316,8 @@ input.on("line", (line) => { }); input.on("close", () => { - if (!stopped && cursor !== transcript.entries.length) { + const processEnded = transcript.entries[cursor - 1]?.type === "runtime_exit"; + if (!stopped && cursor !== transcript.entries.length && !processEnded) { stopWithFailure("ACP replay input closed before transcript completion"); } }); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index becbe3ba66b5e..d383acdbc3a7b 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - tests inject swaps at the native open boundary. +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeFSP from "node:fs/promises"; @@ -16,6 +17,7 @@ import * as TestClock from "effect/testing/TestClock"; import { HttpServerResponse } from "effect/http"; import { vi } from "vite-plus/test"; +import * as AssetSigner from "./AssetSigner.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; @@ -104,8 +106,10 @@ const layerTest = Layer.mergeAll( Layer.provide(WorkspacePaths.layer), Layer.provide(T3ProjectFileLoader.layer), ), - ServerSecretStore.layer.pipe(Layer.provide(layerConfig)), -).pipe(Layer.provideMerge(NodeServices.layer)); + AssetSigner.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer.pipe(Layer.provide(layerConfig))), + ), +).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); // Coder: native app icon and GitHub media cases are not carried. describe("AssetAccess", () => { @@ -122,7 +126,7 @@ describe("AssetAccess", () => { const bytes = yield* readMcpAppHtml(file); expect(bytes?.byteLength).toBe(limit); expect(bytes?.every((byte) => byte === 120)).toBe(true); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("rejects snapshots one byte past the limit and much larger files", () => @@ -136,7 +140,7 @@ describe("AssetAccess", () => { yield* fileSystem.writeFile(file, new Uint8Array(size).fill(120)); expect(yield* readMcpAppHtml(file)).toBeNull(); } - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("preserves UTF-8 bytes across stream chunks and accepts empty snapshots", () => @@ -152,7 +156,7 @@ describe("AssetAccess", () => { expect(bytes?.byteLength).toBe(expected.byteLength); expect(bytes && new TextDecoder().decode(bytes)).toBe(html); } - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // regression: #17026 apply host color scheme to saved MCP apps @@ -164,7 +168,7 @@ describe("AssetAccess", () => { const error = yield* readMcpAppHtml(path.join(directory, "missing.html")).pipe(Effect.flip); expect(error._tag).toBe("PlatformError"); expect(error.reason._tag).toBe("NotFound"); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index dc28916b446fc..dcef1aa0634f9 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -37,7 +37,7 @@ import { import { isNetworkPath } from "@t3tools/shared/path"; import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput"; import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; @@ -46,13 +46,8 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { - base64UrlDecodeUtf8, - base64UrlEncode, - signPayload, - timingSafeEqualBase64Url, -} from "../auth/utils.ts"; -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { base64UrlDecodeUtf8, base64UrlEncode } from "../auth/utils.ts"; +import * as AssetSigner from "./AssetSigner.ts"; import { makeIsServerInternalPath } from "../auth/serverInternalPaths.ts"; import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; @@ -68,7 +63,6 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; export const ASSET_ROUTE_PREFIX = "/api/assets"; -const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; const HTML_ATTACHMENT_TOKEN_BUCKET_MS = 30 * 60 * 1000; @@ -767,7 +761,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i expiresAt, }; if (sourceFaviconPath && canonicalFaviconPath) { - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe( Effect.mapError( (cause) => @@ -777,7 +771,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - const revision = yield* crypto.digest("SHA-256", faviconBytes).pipe( + const revision = yield* digest.digest(Digest.sha256, faviconBytes).pipe( Effect.map(Hex.encode), Effect.mapError( (cause) => @@ -831,16 +825,6 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } } - const secretStore = yield* ServerSecretStore.ServerSecretStore; - const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( - Effect.mapError( - (cause) => - new AssetSigningKeyLoadError({ - resource: input.resource, - cause, - }), - ), - ); if (claims.kind === "project-favicon" || claims.kind === "project-favicon-external") { const issuedAt = yield* Clock.currentTimeMillis; expiresAt = @@ -855,7 +839,13 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i claims = { ...claims, expiresAt }; } const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); - const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; + const signer = yield* AssetSigner.AssetSigner; + const signature = yield* signer + .sign(encodedPayload) + .pipe( + Effect.mapError((cause) => new AssetSigningKeyLoadError({ resource: input.resource, cause })), + ); + const token = `${encodedPayload}.${signature}`; return { relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, expiresAt, @@ -872,13 +862,12 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) return null; - const secretStore = yield* ServerSecretStore.ServerSecretStore; - const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( + const signer = yield* AssetSigner.AssetSigner; + const verified = yield* signer.verify(encodedPayload, signature).pipe( Effect.tapError((cause) => Effect.logError("Failed to load the asset signing key.", { cause })), - Effect.orElseSucceed(() => null), + Effect.orElseSucceed(() => false), ); - if (!signingSecret) return null; - if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null; + if (!verified) return null; const claims = decodeClaims(encodedPayload); if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; diff --git a/apps/server/src/assets/AssetSigner.ts b/apps/server/src/assets/AssetSigner.ts new file mode 100644 index 0000000000000..b113ee55593b2 --- /dev/null +++ b/apps/server/src/assets/AssetSigner.ts @@ -0,0 +1,13 @@ +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; + +import * as PayloadSigner from "../auth/PayloadSigner.ts"; +import type * as ServerSecretStore from "../auth/ServerSecretStore.ts"; + +/** Signs asset and attachment-upload URLs; both share one key. */ +export class AssetSigner extends Context.Service< + AssetSigner, + PayloadSigner.PayloadSigner +>()("t3/assets/AssetSigner") {} + +export const layer = Layer.effect(AssetSigner, PayloadSigner.make("asset-access-signing-key")); diff --git a/apps/server/src/assets/AttachmentUpload.test.ts b/apps/server/src/assets/AttachmentUpload.test.ts index d4181c202f5de..9e13ce882ca82 100644 --- a/apps/server/src/assets/AttachmentUpload.test.ts +++ b/apps/server/src/assets/AttachmentUpload.test.ts @@ -1,8 +1,10 @@ +import * as AssetSigner from "./AssetSigner.ts"; import * as DocumentText from "../provider/DocumentText.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { ChatAttachmentId } from "@t3tools/contracts"; @@ -16,7 +18,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { base64UrlEncode, signPayload } from "../auth/utils.ts"; +import { base64UrlEncode } from "../auth/utils.ts"; import * as ServerConfig from "../config.ts"; import { claimPendingAttachments, @@ -30,9 +32,14 @@ import { validateAttachmentUploadToken, } from "./AttachmentUpload.ts"; -const layerTest = ServerSecretStore.layer.pipe( +const layerTest = AssetSigner.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-attachment-upload-" })), - Layer.provideMerge(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))), + Layer.provideMerge( + DocumentText.layer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), ); const uploadInput = { @@ -147,8 +154,7 @@ describe("AttachmentUpload", () => { it.effect("accepts unexpired image upload tokens issued before file support", () => Effect.gen(function* () { const issued = yield* issueAttachmentUploadUrl(uploadInput); - const secretStore = yield* ServerSecretStore.ServerSecretStore; - const secret = yield* secretStore.getOrCreateRandom("asset-access-signing-key", 32); + const signer = yield* AssetSigner.AssetSigner; const encodedPayload = base64UrlEncode( yield* encodeLegacyAttachmentUploadClaims({ version: 1, @@ -160,7 +166,7 @@ describe("AttachmentUpload", () => { expiresAt: issued.expiresAt, }), ); - const legacyToken = `${encodedPayload}.${signPayload(encodedPayload, secret)}`; + const legacyToken = `${encodedPayload}.${yield* signer.sign(encodedPayload)}`; expect(yield* validateAttachmentUploadToken(legacyToken)).toMatchObject({ type: "image", diff --git a/apps/server/src/assets/AttachmentUpload.ts b/apps/server/src/assets/AttachmentUpload.ts index d1a1b055bfe11..d2ea0aea145ba 100644 --- a/apps/server/src/assets/AttachmentUpload.ts +++ b/apps/server/src/assets/AttachmentUpload.ts @@ -8,7 +8,7 @@ import { } from "@t3tools/contracts"; import { isSvgAttachment } from "@t3tools/shared/image"; import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -27,20 +27,14 @@ import { sweepStalePendingAttachments, } from "../attachmentStore.ts"; import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; -import { - base64UrlDecodeUtf8, - base64UrlEncode, - signPayload, - timingSafeEqualBase64Url, -} from "../auth/utils.ts"; -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { base64UrlDecodeUtf8, base64UrlEncode } from "../auth/utils.ts"; +import * as AssetSigner from "./AssetSigner.ts"; import * as ServerConfig from "../config.ts"; import { inferImageExtension } from "../imageMime.ts"; import * as DocumentText from "../provider/DocumentText.ts"; export const ATTACHMENT_UPLOAD_ROUTE_PREFIX = "/api/attachments/upload"; -const SIGNING_SECRET_NAME = "asset-access-signing-key"; const PENDING_ATTACHMENT_SWEEP_INTERVAL_MS = 15 * 60_000; const lastPendingSweepByDirectory = new Map(); @@ -70,17 +64,9 @@ function decodeClaims(encodedPayload: string): AttachmentUploadClaims | null { } } -const loadSigningSecret = Effect.gen(function* () { - const secretStore = yield* ServerSecretStore.ServerSecretStore; - return yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); -}); - export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(function* ( input: AttachmentCreateUploadUrlInput, ) { - const secret = yield* loadSigningSecret.pipe( - Effect.mapError((cause) => new AttachmentUploadSigningKeyError({ cause })), - ); const config = yield* ServerConfig.ServerConfig; const nowMs = yield* Clock.currentTimeMillis; const previousSweep = lastPendingSweepByDirectory.get(config.attachmentsDir); @@ -120,9 +106,13 @@ export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(f }), ); + const signer = yield* AssetSigner.AssetSigner; + const signature = yield* signer + .sign(encodedPayload) + .pipe(Effect.mapError((cause) => new AttachmentUploadSigningKeyError({ cause }))); return { attachmentId, - relativeUrl: `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/${encodedPayload}.${signPayload(encodedPayload, secret)}`, + relativeUrl: `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/${encodedPayload}.${signature}`, expiresAt, }; }); @@ -135,13 +125,14 @@ export const validateAttachmentUploadToken = Effect.fn("AttachmentUpload.validat return null; } - const secret = yield* loadSigningSecret.pipe( + const signer = yield* AssetSigner.AssetSigner; + const verified = yield* signer.verify(encodedPayload, signature).pipe( Effect.tapError((cause) => Effect.logError("Failed to load the attachment upload signing key.", { cause }), ), - Effect.orElseSucceed(() => null), + Effect.orElseSucceed(() => false), ); - if (!secret || !timingSafeEqualBase64Url(signature, signPayload(encodedPayload, secret))) { + if (!verified) { return null; } @@ -178,8 +169,8 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio attachmentsDir: config.attachmentsDir, relativePath, }); - const crypto = yield* Crypto.Crypto; - const partId = yield* crypto.randomUUIDv4().pipe(Effect.orDie); + const random = yield* SecureRandom.SecureRandom; + const partId = yield* random.uuidV4(); const partPath = resolveAttachmentRelativePath({ attachmentsDir: config.attachmentsDir, relativePath: `${relativePath}.${partId}.part`, @@ -212,7 +203,7 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio } // Publishing without replacement keeps an upload and its cached text immutable. const published = yield* fileSystem.link(partPath, finalPath).pipe( - Effect.map(() => true), + Effect.as(true), Effect.catch((error) => { if (error.reason._tag === "AlreadyExists") return Effect.succeed(false); return Effect.tryPromise({ diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 9a562db017502..afef6ac2a1166 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -13,7 +13,7 @@ import { import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; -const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; +export const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; const ATTACHMENT_ID_FILE_EXTENSION_PATTERN = "[a-z0-9]{1,10}"; @@ -148,6 +148,7 @@ export function threadHtmlRenderAttachmentIds( if (segment === null) return []; return Array.from(items).flatMap((item) => { const attachmentId = + // Coder: no agent preview recordings (#29853); the preview toolkits are not carried. htmlRenderFromToolItem(item)?.attachmentId ?? mcpAppFromToolItem(item)?.attachmentId; return attachmentId !== undefined && parseThreadSegmentFromAttachmentId(attachmentId) === segment diff --git a/apps/server/src/auth/PayloadSigner.ts b/apps/server/src/auth/PayloadSigner.ts new file mode 100644 index 0000000000000..6136f965d7918 --- /dev/null +++ b/apps/server/src/auth/PayloadSigner.ts @@ -0,0 +1,59 @@ +import * as Mac from "effect/crypto/Mac"; +import * as Effect from "effect/Effect"; +import * as Base64Url from "effect/encoding/Base64Url"; +import * as Result from "effect/Result"; + +import * as ServerSecretStore from "./ServerSecretStore.ts"; + +/** HMAC-SHA256 signatures over UTF-8 payloads, as unpadded base64url. */ +export interface PayloadSigner { + readonly sign: (payload: string) => Effect.Effect; + readonly verify: (payload: string, signature: string) => Effect.Effect; +} + +const utf8 = (value: string) => new TextEncoder().encode(value); + +const fromKey = (mac: Mac.Mac["Service"], key: Effect.Effect): PayloadSigner => ({ + sign: (payload) => + key.pipe( + Effect.flatMap((loaded) => mac.sign(loaded, utf8(payload)).pipe(Effect.orDie)), + Effect.map(Base64Url.encode), + ), + verify: (payload, signature) => { + const tag = Base64Url.decode(signature); + return Result.isFailure(tag) + ? Effect.succeed(false) + : key.pipe( + Effect.flatMap((loaded) => + mac.verify(loaded, tag.success, utf8(payload)).pipe(Effect.orDie), + ), + ); + }, +}); + +/** A signer for secret bytes the caller already holds. */ +export const fromSecret = Effect.fn("PayloadSigner.fromSecret")(function* (secret: Uint8Array) { + const mac = yield* Mac.Mac; + const key = yield* Mac.key(Mac.hmacSha256, secret).pipe(Effect.orDie); + return fromKey(mac, Effect.succeed(key)); +}); + +/** A signer keyed by a named server secret, loaded on first use so store failures reach callers. */ +export const make = Effect.fn("PayloadSigner.make")(function* (secretName: string) { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const mac = yield* Mac.Mac; + let key: Mac.Key | undefined; + const loadKey = Effect.suspend(() => + key !== undefined + ? Effect.succeed(key) + : secrets.getOrCreateRandom(secretName, 32).pipe( + Effect.flatMap((bytes) => Mac.key(Mac.hmacSha256, bytes).pipe(Effect.orDie)), + Effect.tap((loaded) => + Effect.sync(() => { + key = loaded; + }), + ), + ), + ); + return fromKey(mac, loadKey); +}); diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index b3dd615c8d4d7..08a33e2fde3c5 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -1,6 +1,6 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -36,7 +36,7 @@ const layerPermissionDeniedFileSystem = Layer.effect( ), } satisfies FileSystem.FileSystem; }), -).pipe(Layer.provide(NodeServices.layer)); +).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); const layerPermissionDeniedSecretStore = () => ServerSecretStore.layer.pipe( @@ -63,7 +63,7 @@ const layerRenameFailureFileSystem = Layer.effect( ), } satisfies FileSystem.FileSystem; }), -).pipe(Layer.provide(NodeServices.layer)); +).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); const layerRenameFailureSecretStore = () => ServerSecretStore.layer.pipe( @@ -92,24 +92,20 @@ const layerConcurrentReadMissFileSystem = Layer.effect( yield* Deferred.succeed(readBarrier, void 0); } yield* Deferred.await(readBarrier); - return yield* Effect.failCause( - Cause.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "FileSystem", - method: "readFile", - pathOrDescriptor: String(path), - description: "Secret file does not exist yet.", - }), - ), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: String(path), + description: "Secret file does not exist yet.", + }); }); }), ) : fileSystem.readFile(path), } satisfies FileSystem.FileSystem; }), -).pipe(Layer.provide(NodeServices.layer)); +).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); const layerConcurrentCreateSecretStore = () => ServerSecretStore.layer.pipe( @@ -117,7 +113,7 @@ const layerConcurrentCreateSecretStore = () => Layer.provideMerge(layerConcurrentReadMissFileSystem), ); -it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ServerSecretStore.layer", (it) => { it.effect("reuses an existing secret instead of regenerating it", () => Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index 100e0e19ca2ae..e950acb38a71d 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -1,5 +1,5 @@ import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -152,7 +152,7 @@ export class ServerSecretStore extends Context.Service< /** @public */ export const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; + const crypto = yield* SecureRandom.SecureRandom; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; @@ -188,7 +188,7 @@ export const make = Effect.gen(function* () { const set: ServerSecretStore["Service"]["set"] = (name, value) => { const secretPath = resolveSecretPath(name); - return crypto.randomUUIDv4().pipe( + return crypto.uuidV4().pipe( Effect.mapError( (cause) => new SecretStoreTemporaryPathError({ @@ -227,7 +227,7 @@ export const make = Effect.gen(function* () { // AlreadyExists, so a concurrent reader never observes a partially written secret. const create: ServerSecretStore["Service"]["create"] = (name, value) => { const secretPath = resolveSecretPath(name); - return crypto.randomUUIDv4().pipe( + return crypto.uuidV4().pipe( Effect.mapError( (cause) => new SecretStoreTemporaryPathError({ diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 6bcc8a3942cdf..c34f8d3ceee17 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -1,7 +1,5 @@ -// Coder: only upstream's signing helpers, for signed asset URLs; sessions and client metadata -// belong to upstream's HTTP auth, which is not carried. -// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual. -import * as NodeCrypto from "node:crypto"; +// Coder: only upstream's encoding helpers that signed asset URLs use; sessions, cookies, and client +// metadata belong to upstream's HTTP auth, which is not carried. import * as Base64Url from "effect/encoding/Base64Url"; import * as Result from "effect/Result"; @@ -14,16 +12,3 @@ export function base64UrlEncode(input: string | Uint8Array): string { export function base64UrlDecodeUtf8(input: string): string { return Result.getOrThrow(Base64Url.decodeString(input)); } - -export function signPayload(payload: string, secret: Uint8Array): string { - return NodeCrypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url"); -} - -export function timingSafeEqualBase64Url(left: string, right: string): boolean { - const leftBuffer = Buffer.from(left, "base64url"); - const rightBuffer = Buffer.from(right, "base64url"); - if (leftBuffer.length !== rightBuffer.length) { - return false; - } - return NodeCrypto.timingSafeEqual(leftBuffer, rightBuffer); -} diff --git a/apps/server/src/background/BackgroundPolicy.ts b/apps/server/src/background/BackgroundPolicy.ts index 4c85ed5979a43..cccaf68a1857b 100644 --- a/apps/server/src/background/BackgroundPolicy.ts +++ b/apps/server/src/background/BackgroundPolicy.ts @@ -14,7 +14,13 @@ export class BackgroundPolicy extends Context.Service< } >()("t3/background/BackgroundPolicy") {} +const shouldRunScopeWork: BackgroundPolicy["Service"]["shouldRunScopeWork"] = Effect.fn( + "BackgroundPolicy.shouldRunScopeWork", +)(function* () { + return true; +}); + export const layer = Layer.succeed(BackgroundPolicy, { - shouldRunScopeWork: () => Effect.succeed(true), + shouldRunScopeWork, shouldRunOpportunisticWork: Effect.succeed(true), }); diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index fb4513c2a16f0..062405d533216 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -7,7 +7,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -84,7 +84,7 @@ function buildTurnDiffResult( export const make = Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; const checkpointStore = yield* CheckpointStore.CheckpointStore; - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const getTurnDiff: CheckpointDiffQuery["Service"]["getTurnDiff"] = Effect.fn("getTurnDiff")( function* (input) { @@ -186,7 +186,7 @@ export const make = Effect.gen(function* () { : yield* checkpointRefForScopeOrdinal({ scopeId: firstScope.id, ordinalWithinScope: 0, - }).pipe(Effect.provideService(Crypto.Crypto, crypto)) + }).pipe(Effect.provideService(Digest.Digest, digest)) : ( parentCheckpoint ?? readyCheckpoints.find( diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index f7357771c3276..36e58024adbae 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodePath from "node:path"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { CheckpointRef, ThreadId, type VcsError } from "@t3tools/contracts"; @@ -26,17 +27,19 @@ function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): Chec const layerServerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-store-test-", }); -const layerVcsProcessTest = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); +const layerVcsProcessTest = VcsProcess.layer.pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), +); const layerVcsDriverTest = VcsDriverRegistry.layer.pipe(Layer.provide(layerVcsProcessTest)); const layerCheckpointStoreTest = CheckpointStore.layer.pipe( Layer.provideMerge(layerVcsDriverTest), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const layerTest = layerCheckpointStoreTest.pipe( Layer.provideMerge(layerVcsProcessTest), Layer.provideMerge(layerVcsDriverTest), Layer.provideMerge(layerServerConfig), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); function makeTmpDir( @@ -150,21 +153,28 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { }), ); - it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () => + it.effect("keeps the patch format clients parse when the repository changes it", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); yield* git(tmp, ["config", "diff.noprefix", "true"]); + yield* git(tmp, ["config", "diff.suppressBlankEmpty", "true"]); + yield* git(tmp, ["config", "diff.submodule", "log"]); + const sub = NodePath.join(tmp, "sub"); + yield* git(tmp, ["init", "sub"]); + yield* initRepoWithCommit(sub); const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("thread-checkpoint-store-noprefix"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# test\n\nbefore\n"); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: fromCheckpointRef, }); - yield* writeTextFile(NodePath.join(tmp, "README.md"), "# changed\n"); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# test\n\nafter\n"); + yield* git(sub, ["commit", "--allow-empty", "-m", "moved"]); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef, @@ -178,6 +188,9 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { }); expect(diff).toContain("diff --git a/README.md b/README.md"); + expect(diff).toContain(" # test\n \n-before\n+after\n"); + expect(diff).toContain("diff --git a/sub b/sub"); + expect(diff).toContain("+Subproject commit "); }), ); @@ -519,5 +532,40 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { ); }), ); + + it.effect("keeps paths from signed commits under log.showSignature", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const signingKey = NodePath.join(yield* makeTmpDir("checkpoint-signing-key-"), "key"); + yield* (yield* VcsProcess.VcsProcess).run({ + operation: "CheckpointStore.test.sshKeygen", + command: "ssh-keygen", + cwd: tmp, + args: ["-q", "-t", "ed25519", "-N", "", "-f", signingKey], + timeoutMs: 10_000, + }); + yield* git(tmp, ["config", "gpg.format", "ssh"]); + yield* git(tmp, ["config", "user.signingkey", `${signingKey}.pub`]); + yield* git(tmp, ["config", "log.showSignature", "true"]); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-authored-paths-signed"); + const refs = { + cwd: tmp, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: refs.fromCheckpointRef, + }); + yield* writeTextFile(NodePath.join(tmp, "kept.txt"), "kept\n"); + yield* git(tmp, ["add", "kept.txt"]); + yield* git(tmp, ["commit", "-S", "-m", "kept"]); + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + + expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual(new Set(["kept.txt"])); + }), + ); }); }); diff --git a/apps/server/src/coderAssets.test.ts b/apps/server/src/coderAssets.test.ts index 02f1d4b100f13..0fb9ad6de12f6 100644 --- a/apps/server/src/coderAssets.test.ts +++ b/apps/server/src/coderAssets.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CODER_ASSET_BODY_CHUNK_BYTES, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; @@ -11,6 +12,7 @@ import * as Stream from "effect/Stream"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import { issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import * as AssetSigner from "./assets/AssetSigner.ts"; import { makeServeAsset } from "./coderAssets.ts"; import * as ServerConfig from "./config.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; @@ -25,16 +27,18 @@ const layerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), { }); const layerTest = Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({ getTurnItem: () => Effect.succeed(null) }), - Layer.mock(DocumentText.DocumentText)({ ensure: () => Effect.succeed(undefined) }), + Layer.mock(DocumentText.DocumentText)({ ensure: () => Effect.undefined }), Layer.succeed(BuiltInDrivers.SourceControlDrivers, []), layerConfig, WorkspacePaths.layer, - ServerSecretStore.layer.pipe(Layer.provide(layerConfig)), + AssetSigner.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer.pipe(Layer.provide(layerConfig))), + ), ProjectFaviconResolver.layer.pipe( Layer.provide(WorkspacePaths.layer), Layer.provide(T3ProjectFileLoader.layer), ), -).pipe(Layer.provideMerge(NodeServices.layer)); +).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); type ServeAsset = Effect.Success; diff --git a/apps/server/src/coderAssets.ts b/apps/server/src/coderAssets.ts index 8fab7ac715c93..c3c821a0a9a98 100644 --- a/apps/server/src/coderAssets.ts +++ b/apps/server/src/coderAssets.ts @@ -16,7 +16,8 @@ import { type CoderWriteRequestBodyInput, } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import type * as Crypto from "effect/Crypto"; +import type * as Digest from "effect/crypto/Digest"; +import type * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import type * as FileSystem from "effect/FileSystem"; import { @@ -28,8 +29,10 @@ import { import * as Layer from "effect/Layer"; import type * as Path from "effect/Path"; import * as Stream from "effect/Stream"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - Coder: tells a route's Node stream body from a web one. import { Readable } from "node:stream"; +import type * as AssetSigner from "./assets/AssetSigner.ts"; import type * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import type * as ServerConfig from "./config.ts"; import { layerAssetRoute, layerAttachmentUploadRoute } from "./http.ts"; @@ -56,6 +59,7 @@ function makeRequestBodies() { for (const [id, at] of finished) if (now - at > REQUEST_BODY_TTL_MS) finished.delete(id); }; const get = (id: string): RequestBody | null => { + // @effect-diagnostics-next-line globalDate:off - Coder: the body registry is synchronous bookkeeping. sweep(Date.now()); if (finished.has(id)) return null; const existing = open.get(id); @@ -64,6 +68,7 @@ function makeRequestBodies() { const body = { readable, writer: writable.getWriter() }; open.set(id, body); // A chunk whose request never arrives is dropped instead of waiting forever. + // @effect-diagnostics-next-line globalTimers:off - Coder: the body registry is synchronous bookkeeping. setTimeout(() => { if (open.get(id) === body && !body.readable.locked) finish(id); }, REQUEST_BODY_TTL_MS).unref(); @@ -72,6 +77,7 @@ function makeRequestBodies() { const finish = (id: string) => { const body = open.get(id); open.delete(id); + // @effect-diagnostics-next-line globalDate:off - Coder: the body registry is synchronous bookkeeping. finished.set(id, Date.now()); if (body) void body.writer.abort(new Error("The request finished.")).catch(() => undefined); }; @@ -119,12 +125,14 @@ export const makeServeAsset = Effect.gen(function* () { ); const bodies = makeRequestBodies(); const services = yield* Effect.context< - | Crypto.Crypto + | Digest.Digest + | SecureRandom.SecureRandom | FileSystem.FileSystem | Path.Path | Orchestrator.OrchestratorV2 | ServerConfig.ServerConfig | ServerSecretStore.ServerSecretStore + | AssetSigner.AssetSigner | ProjectFaviconResolver.ProjectFaviconResolver | WorkspacePaths.WorkspacePaths | DocumentText.DocumentText diff --git a/apps/server/src/coderEnvironment.ts b/apps/server/src/coderEnvironment.ts index f6d5459580ad6..d428ffea101ba 100644 --- a/apps/server/src/coderEnvironment.ts +++ b/apps/server/src/coderEnvironment.ts @@ -6,8 +6,9 @@ import { ORCHESTRATION_PROTOCOL_VERSION, PROVIDER_SEND_TURN_MAX_FILE_BYTES, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -46,6 +47,7 @@ export class ServerEnvironmentIdentity extends Context.Service< { readonly getEnvironmentId: Effect.Effect; } + // @effect-diagnostics-next-line deterministicKeys:off - upstream's key for the service ThreadManagementService reads. >()("t3/environment/ServerEnvironment/ServerEnvironmentIdentity") {} // Coder: upstream's capabilities minus the cut surfaces in CODER_OMITTED_CAPABILITIES. Every @@ -126,7 +128,7 @@ export const CODER_OMITTED_CAPABILITIES = { const makeIdentity = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const serverConfig = yield* ServerConfig.ServerConfig; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const readPersistedEnvironmentId = Effect.gen(function* () { const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe( @@ -200,7 +202,7 @@ const makeIdentity = Effect.gen(function* () { return persisted; } - const generated = yield* crypto.randomUUIDv4(); + const generated = yield* random.uuidV4(); yield* persistEnvironmentId(generated, "create"); let winner = yield* readPersistedEnvironmentId; if (winner === null) { @@ -236,13 +238,14 @@ export const layer = Layer.effect( const config = yield* ServerConfig.ServerConfig; const path = yield* Path.Path; const environmentId = yield* (yield* ServerEnvironmentIdentity).getEnvironmentId; - const configuredLabel = process.env.T3_CODER_WORKSPACE_LABEL?.trim(); + const env = yield* HostProcess.Environment; + const configuredLabel = env.T3_CODER_WORKSPACE_LABEL?.trim(); return CoderEnvironment.of({ descriptor: { environmentId, label: configuredLabel || path.basename(config.cwd) || "Coder workspace", platform: { os: "linux", arch: platformArch() }, - serverVersion: process.env.T3_CODER_BUILD_VERSION?.trim() || packageJson.version, + serverVersion: env.T3_CODER_BUILD_VERSION?.trim() || packageJson.version, orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, capabilities: CODER_ENVIRONMENT_CAPABILITIES, }, diff --git a/apps/server/src/coderHub.ts b/apps/server/src/coderHub.ts index 1f40d3b2ee047..08a37952301ec 100644 --- a/apps/server/src/coderHub.ts +++ b/apps/server/src/coderHub.ts @@ -168,16 +168,16 @@ export const make = Effect.gen(function* () { return CoderHub.of({ outbox, - reply: (reply) => - Effect.suspend(() => { - const deferred = pending.get(reply.id); - return deferred === undefined ? Effect.void : Deferred.succeed(deferred, reply); - }).pipe(Effect.asVoid), - call: (input) => + reply: Effect.fn("CoderHub.reply")(function* (reply) { + const deferred = pending.get(reply.id); + if (deferred !== undefined) yield* Deferred.succeed(deferred, reply); + }), + call: Effect.fn("CoderHub.call")((input) => request( (id) => ({ _tag: "Call", id, ...input }), (reply) => (reply._tag === "Call" ? reply.outcome : undefined), ), + ), listLinks: request( (id) => ({ _tag: "ListLinks", id }), (reply) => (reply._tag === "ListLinks" ? reply.links : undefined), diff --git a/apps/server/src/coderServer.ts b/apps/server/src/coderServer.ts index 7c39678a4a821..adbb397acd3e7 100644 --- a/apps/server/src/coderServer.ts +++ b/apps/server/src/coderServer.ts @@ -7,7 +7,7 @@ import { FetchHttpClient } from "effect/http"; import * as StorageCleanup from "./storageCleanup.ts"; import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as DelegatedTaskQuestionReactor from "./orchestration-v2/DelegatedTaskQuestionReactor.ts"; -import * as EventStoreV2 from "./orchestration-v2/EventStore.ts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as ProjectionMaintenance from "./orchestration-v2/ProjectionMaintenance.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; @@ -351,12 +351,7 @@ export const makeCoderRuntimeLayer = () => { const startedRuntime = Layer.mergeAll( StorageCleanup.layer.pipe(Layer.provide(ProjectionStoreV2.layer)), // Background history compaction and space reclaim, as upstream (see DatabaseStorage.ts). - DatabaseStorage.layer.pipe( - Layer.provide(ProjectionMaintenance.layer), - Layer.provide( - Layer.merge(EventStoreV2.layerFromOrchestrationEventStore, ProjectionStoreV2.layer), - ), - ), + DatabaseStorage.layer.pipe(Layer.provide(ProjectionMaintenance.layer)), ).pipe(Layer.provideMerge(runtimeStartup)); const services = Layer.mergeAll( startedRuntime, @@ -367,8 +362,8 @@ export const makeCoderRuntimeLayer = () => { // Every agent and terminal spawn reads this, so it sits below everything. Layer.provideMerge(AgentScopeLive.layer), // As upstream's platform services: attachment uploads, agent attachments, and the project - // toolkit read office documents' extracted text. - Layer.provideMerge(DocumentText.layer), + // toolkit read office documents' extracted text; Node's crypto backs random ids and digests. + Layer.provideMerge(DocumentText.layer.pipe(Layer.provideMerge(NodeCrypto.layer))), ); // Upstream provides the agent-session scanner beside its WS layer; the helper RPC layer // takes it here. diff --git a/apps/server/src/coderWorkspaceStats.ts b/apps/server/src/coderWorkspaceStats.ts index bacf16abca2fa..986583953c33b 100644 --- a/apps/server/src/coderWorkspaceStats.ts +++ b/apps/server/src/coderWorkspaceStats.ts @@ -13,6 +13,11 @@ const STAT_TIMEOUT = "15 seconds"; const MAX_STAT_OUTPUT_BYTES = 64 * 1024; const decodeUsage = Schema.decodeUnknownEffect(CoderWorkspaceResourceUsage); +const decodeStatJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +const usageError = () => + new CoderWorkspaceResourceUsageError({ + message: "Could not read the workspace's resource usage with `coder stat`.", + }); /** * The Coder agent's own CLI. An SSH session's shell has it on PATH, but the helper's environment @@ -34,22 +39,12 @@ export const readCoderWorkspaceResourceUsage = Effect.gen(function* () { maxOutputBytes: MAX_STAT_OUTPUT_BYTES, }) .pipe( - Effect.flatMap((output) => - output.code === 0 - ? Effect.try(() => JSON.parse(output.stdout) as unknown) - : Effect.fail(new Error(`coder stat exited with code ${String(output.code)}.`)), - ), + Effect.filterOrFail((output) => output.code === 0, usageError), + Effect.flatMap((output) => decodeStatJson(output.stdout)), ); const [cpu, memory, disk] = yield* Effect.all( [stat(["cpu"]), stat(["mem"]), stat(["disk", "--path", NodeOS.homedir()])], { concurrency: "unbounded" }, ); return yield* decodeUsage({ cpu, memory, disk }); -}).pipe( - Effect.mapError( - () => - new CoderWorkspaceResourceUsageError({ - message: "Could not read the workspace's resource usage with `coder stat`.", - }), - ), -); +}).pipe(Effect.mapError(usageError)); diff --git a/apps/server/src/coderWs.ts b/apps/server/src/coderWs.ts index da384c51d4aff..1259b8fc88685 100644 --- a/apps/server/src/coderWs.ts +++ b/apps/server/src/coderWs.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import type * as RpcGroup from "effect/rpc/RpcGroup"; import { type CommandId, @@ -73,392 +74,376 @@ interface UpstreamHelpers { } /** The fork-only handlers. `ws.ts` spreads these after its own handlers. */ -export const makeHandlers = ({ serverCommandId, refreshGitStatus }: UpstreamHelpers) => - Effect.gen(function* () { - const threadManagement = yield* ThreadManagementService.ThreadManagementService; - const projectService = yield* ProjectService.ProjectService; - const orchestrationEngine = yield* Orchestrator.OrchestratorV2; - const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; - const review = yield* ReviewService.ReviewService; - const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const terminalManager = yield* TerminalManager.TerminalManager; - const config = yield* ServerConfig.ServerConfig; - const assets = yield* makeServeAsset; - const pullRequests = yield* PullRequestService.PullRequestService; - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const { descriptor } = yield* CoderEnvironment.CoderEnvironment; - const processRunner = yield* ProcessRunner.ProcessRunner; +export const makeHandlers = Effect.fn("makeHandlers")(function* ({ + serverCommandId, + refreshGitStatus, +}: UpstreamHelpers) { + const threadManagement = yield* ThreadManagementService.ThreadManagementService; + const projectService = yield* ProjectService.ProjectService; + const orchestrationEngine = yield* Orchestrator.OrchestratorV2; + const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const review = yield* ReviewService.ReviewService; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const terminalManager = yield* TerminalManager.TerminalManager; + const config = yield* ServerConfig.ServerConfig; + const assets = yield* makeServeAsset; + const pullRequests = yield* PullRequestService.PullRequestService; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const { descriptor } = yield* CoderEnvironment.CoderEnvironment; + const processRunner = yield* ProcessRunner.ProcessRunner; - // Coder: upstream's HTTP bounded thread snapshot, served over helper stdio. - const loadThreadSnapshotWindow = (threadId: ThreadId, deferWork: boolean) => - threadManagement - .getThreadSnapshotWindow(threadId, { - rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, - deferWork, - userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, - }) - .pipe( - Effect.map((snapshot) => ({ - ...snapshot, - projection: projectThreadProjectionForWire(snapshot.projection), - })), - Effect.mapError( - (cause) => - new OrchestrationV2GetThreadProjectionError({ - threadId, - message: `Failed to load orchestration V2 thread ${threadId}`, - cause, - }), - ), - ); + // Coder: upstream's HTTP bounded thread snapshot, served over helper stdio. + const loadThreadSnapshotWindow = (threadId: ThreadId, deferWork: boolean) => + threadManagement + .getThreadSnapshotWindow(threadId, { + rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, + deferWork, + userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, + }) + .pipe( + Effect.map((snapshot) => ({ + ...snapshot, + projection: projectThreadProjectionForWire(snapshot.projection), + })), + Effect.mapError( + (cause) => + new OrchestrationV2GetThreadProjectionError({ + threadId, + message: `Failed to load orchestration V2 thread ${threadId}`, + cause, + }), + ), + ); - const getThreadBoundedSnapshot = Effect.fn("ws.orchestrationV2.getThreadBoundedSnapshot")( - function* (threadId: ThreadId, deferWork: boolean) { - const snapshot = yield* loadThreadSnapshotWindow(threadId, deferWork); - const bounded = buildBoundedThreadProjection({ - projection: snapshot.projection, - snapshotSequence: snapshot.snapshotSequence, + const getThreadBoundedSnapshot = Effect.fn("ws.orchestrationV2.getThreadBoundedSnapshot")( + function* (threadId: ThreadId, deferWork: boolean) { + const snapshot = yield* loadThreadSnapshotWindow(threadId, deferWork); + const bounded = buildBoundedThreadProjection({ + projection: snapshot.projection, + snapshotSequence: snapshot.snapshotSequence, + }); + const result = { + snapshotSequence: snapshot.snapshotSequence, + projection: bounded.projection, + historyCursor: bounded.historyCursor, + hasMoreHistory: bounded.hasMoreHistory, + latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, + }; + if (exceedsHelperStdioFrame(result)) { + return yield* new OrchestrationV2GetThreadProjectionError({ + threadId, + message: "The thread snapshot exceeds the helper stdio frame limit.", }); - const result = { - snapshotSequence: snapshot.snapshotSequence, - projection: bounded.projection, - historyCursor: bounded.historyCursor, - hasMoreHistory: bounded.hasMoreHistory, - latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, - }; - if (exceedsHelperStdioFrame(result)) { - return yield* new OrchestrationV2GetThreadProjectionError({ - threadId, - message: "The thread snapshot exceeds the helper stdio frame limit.", - }); - } - return result; - }, + } + return result; + }, + ); + + // Coder: upstream's HTTP thread transcript route, served over helper stdio. + const getThreadTranscript = Effect.fn("ws.orchestrationV2.getThreadTranscript")(function* ( + threadId: ThreadId, + ) { + const transcript = yield* threadManagement.getThreadTranscript(threadId).pipe( + Effect.provideService(CoderEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(descriptor.environmentId), + }), + Effect.catchTag("ThreadTranscriptTooLargeError", () => + Effect.fail(new OrchestrationThreadTranscriptTooLargeError({ threadId })), + ), + Effect.mapError((cause) => + cause._tag === "OrchestrationThreadTranscriptTooLargeError" + ? cause + : new OrchestrationV2GetThreadProjectionError({ + threadId, + message: `Failed to load orchestration V2 thread ${threadId} transcript`, + cause, + }), + ), ); + if (exceedsHelperStdioFrame(transcript)) { + return yield* new OrchestrationThreadTranscriptTooLargeError({ threadId }); + } + return transcript; + }); - // Coder: upstream's HTTP thread transcript route, served over helper stdio. - const getThreadTranscript = Effect.fn("ws.orchestrationV2.getThreadTranscript")(function* ( - threadId: ThreadId, - ) { - const transcript = yield* threadManagement.getThreadTranscript(threadId).pipe( - Effect.provideService(CoderEnvironment.ServerEnvironmentIdentity, { - getEnvironmentId: Effect.succeed(descriptor.environmentId), - }), - Effect.catchTag("ThreadTranscriptTooLargeError", () => - Effect.fail(new OrchestrationThreadTranscriptTooLargeError({ threadId })), - ), - Effect.mapError((cause) => - cause._tag === "OrchestrationThreadTranscriptTooLargeError" - ? cause - : new OrchestrationV2GetThreadProjectionError({ - threadId, - message: `Failed to load orchestration V2 thread ${threadId} transcript`, - cause, - }), - ), - ); - if (exceedsHelperStdioFrame(transcript)) { - return yield* new OrchestrationThreadTranscriptTooLargeError({ threadId }); + // Coder: upstream's HTTP older-history page, served over helper stdio. + const getThreadHistoryPage = Effect.fn("ws.orchestrationV2.getThreadHistoryPage")( + function* (input: { + readonly threadId: ThreadId; + readonly cursor: string; + readonly throughEntryId?: string | undefined; + readonly view?: "conversation" | "activity" | "work" | undefined; + readonly deferWork?: boolean; + }) { + const failure = (cause: unknown) => + new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: `Failed to load orchestration V2 thread ${input.threadId} history`, + cause, + }); + yield* Effect.try({ + try: () => decodeThreadHistoryCursor(input.cursor), + catch: (cause) => + Schema.is(InvalidThreadHistoryCursorError)(cause) + ? new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: "The thread history cursor is invalid.", + }) + : failure(cause), + }); + const page = yield* threadManagement + .getThreadHistoryPage( + input.threadId, + input.cursor, + input.throughEntryId, + input.view === "conversation", + input.view === "work" ? "work" : input.deferWork === true ? "deferred" : undefined, + ) + .pipe(Effect.mapError(failure)); + if (exceedsHelperStdioFrame(page)) { + return yield* new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: "The thread history page exceeds the helper stdio frame limit.", + }); } - return transcript; + return page; + }, + ); + + // Coder: rename the managed workspace branch and worktree together through workspace Git. + const renameThreadBranchError = (detail: string, cause?: unknown) => + new VcsRenameThreadBranchError({ + detail, + ...(cause === undefined ? {} : { cause }), }); - // Coder: upstream's HTTP older-history page, served over helper stdio. - const getThreadHistoryPage = Effect.fn("ws.orchestrationV2.getThreadHistoryPage")( - function* (input: { - readonly threadId: ThreadId; - readonly cursor: string; - readonly throughEntryId?: string | undefined; - readonly view?: "conversation" | "activity" | "work" | undefined; - readonly deferWork?: boolean; - }) { - const failure = (cause: unknown) => - new OrchestrationV2GetThreadProjectionError({ - threadId: input.threadId, - message: `Failed to load orchestration V2 thread ${input.threadId} history`, - cause, - }); - yield* Effect.try({ - try: () => decodeThreadHistoryCursor(input.cursor), - catch: (cause) => - cause instanceof InvalidThreadHistoryCursorError - ? new OrchestrationV2GetThreadProjectionError({ - threadId: input.threadId, - message: "The thread history cursor is invalid.", - }) - : failure(cause), - }); - const page = yield* threadManagement - .getThreadHistoryPage( - input.threadId, - input.cursor, - input.throughEntryId, - input.view === "conversation", - input.view === "work" ? "work" : input.deferWork === true ? "deferred" : undefined, - ) - .pipe(Effect.mapError(failure)); - if (exceedsHelperStdioFrame(page)) { - return yield* new OrchestrationV2GetThreadProjectionError({ - threadId: input.threadId, - message: "The thread history page exceeds the helper stdio frame limit.", - }); - } - return page; - }, - ); + // Coder: rename the managed workspace branch and worktree together through workspace Git. + const nextManagedWorktreePathForBranch = Effect.fn("nextManagedWorktreePathForBranch")(function* ( + branch: string, + currentPath?: string, + ) { + const worktreesRoot = path.resolve(config.worktreesDir); + const resolvedCurrentPath = currentPath ? path.resolve(currentPath) : undefined; + const currentParent = resolvedCurrentPath ? path.dirname(resolvedCurrentPath) : worktreesRoot; + if ( + resolvedCurrentPath && + currentParent !== worktreesRoot && + path.dirname(currentParent) !== worktreesRoot + ) { + return yield* renameThreadBranchError( + "Only worktree folders managed by T3 Code can be renamed from the UI.", + ); + } - // Coder: rename the managed workspace branch and worktree together through workspace Git. - const renameThreadBranchError = (detail: string, cause?: unknown) => - new VcsRenameThreadBranchError({ - detail, - ...(cause === undefined ? {} : { cause }), - }); + const baseName = sanitizeBranchFragment(branch).replaceAll("/", "-"); + for (let suffix = 1; suffix <= 1_000; suffix += 1) { + const name = suffix === 1 ? baseName : `${baseName}-${suffix}`; + const candidate = path.join(currentParent, name); + if (candidate === resolvedCurrentPath || !(yield* fileSystem.exists(candidate))) { + return candidate; + } + } + return yield* renameThreadBranchError( + "Could not find an available folder name for this worktree.", + ); + }); - // Coder: rename the managed workspace branch and worktree together through workspace Git. - const nextManagedWorktreePathForBranch = Effect.fn("nextManagedWorktreePathForBranch")( - function* (branch: string, currentPath?: string) { - const worktreesRoot = path.resolve(config.worktreesDir); - const resolvedCurrentPath = currentPath ? path.resolve(currentPath) : undefined; - const currentParent = resolvedCurrentPath - ? path.dirname(resolvedCurrentPath) - : worktreesRoot; - if ( - resolvedCurrentPath && - currentParent !== worktreesRoot && - path.dirname(currentParent) !== worktreesRoot - ) { - return yield* Effect.fail( - renameThreadBranchError( - "Only worktree folders managed by T3 Code can be renamed from the UI.", - ), - ); - } + // Coder: rename the managed workspace branch and worktree together through workspace Git. + const nextManagedWorktreePath = (currentPath: string, branch: string) => + nextManagedWorktreePathForBranch(branch, currentPath); - const baseName = sanitizeBranchFragment(branch).replaceAll("/", "-"); - for (let suffix = 1; suffix <= 1_000; suffix += 1) { - const name = suffix === 1 ? baseName : `${baseName}-${suffix}`; - const candidate = path.join(currentParent, name); - if (candidate === resolvedCurrentPath || !(yield* fileSystem.exists(candidate))) { - return candidate; - } - } - return yield* Effect.fail( - renameThreadBranchError("Could not find an available folder name for this worktree."), - ); - }, + // Coder: rename the managed workspace branch and worktree together through workspace Git. + const renameThreadBranch = Effect.fn("renameThreadBranch")(function* (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly expectedBranch: string; + readonly newBranch: string; + readonly renameWorktreeFolder: boolean; + }) { + const thread = yield* threadManagement.getThreadShell(input.threadId).pipe( + Effect.map((shell) => shell ?? undefined), + Effect.mapError((cause) => renameThreadBranchError("Could not load the thread.", cause)), ); + if (!thread) { + return yield* renameThreadBranchError("The thread no longer exists."); + } + if (thread.branch !== input.expectedBranch) { + return yield* renameThreadBranchError( + `The thread branch changed from ${input.expectedBranch} to ${thread.branch ?? "an unknown branch"}. Refresh and try again.`, + ); + } - // Coder: rename the managed workspace branch and worktree together through workspace Git. - const nextManagedWorktreePath = (currentPath: string, branch: string) => - nextManagedWorktreePathForBranch(branch, currentPath); + const project = yield* projectService.getShell(thread.projectId).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError((cause) => renameThreadBranchError("Could not load the project.", cause)), + ); + if (!project) { + return yield* renameThreadBranchError("The project no longer exists."); + } - // Coder: rename the managed workspace branch and worktree together through workspace Git. - const renameThreadBranch = Effect.fn("renameThreadBranch")(function* (input: { - readonly threadId: ThreadId; - readonly cwd: string; - readonly expectedBranch: string; - readonly newBranch: string; - readonly renameWorktreeFolder: boolean; - }) { - const thread = yield* threadManagement.getThreadShell(input.threadId).pipe( - Effect.map((shell) => shell ?? undefined), - Effect.mapError((cause) => renameThreadBranchError("Could not load the thread.", cause)), + const currentCwd = thread.worktreePath ?? project.workspaceRoot; + if (path.resolve(input.cwd) !== path.resolve(currentCwd)) { + return yield* renameThreadBranchError("The thread workspace changed. Refresh and try again."); + } + const localStatus = yield* gitWorkflow + .localStatus({ cwd: currentCwd }) + .pipe( + Effect.mapError((cause) => + renameThreadBranchError("Could not read the checked-out branch.", cause), + ), + ); + if (localStatus.refName !== input.expectedBranch) { + return yield* renameThreadBranchError( + `The checkout is on ${localStatus.refName ?? "a detached HEAD"}, not ${input.expectedBranch}. Refresh and try again.`, ); - if (!thread) { - return yield* Effect.fail(renameThreadBranchError("The thread no longer exists.")); + } + + let nextWorktreePath = thread.worktreePath; + if (input.renameWorktreeFolder) { + if (!thread.worktreePath) { + return yield* renameThreadBranchError( + "This thread does not have a dedicated worktree folder.", + ); } - if (thread.branch !== input.expectedBranch) { - return yield* Effect.fail( - renameThreadBranchError( - `The thread branch changed from ${input.expectedBranch} to ${thread.branch ?? "an unknown branch"}. Refresh and try again.`, + const shell = yield* threadManagement + .getShellSnapshot({ location: "active" }) + .pipe( + Effect.mapError((cause) => + renameThreadBranchError("Could not inspect worktree ownership.", cause), ), ); + if ( + shell.threads.some( + (other) => other.id !== thread.id && other.worktreePath === thread.worktreePath, + ) + ) { + return yield* renameThreadBranchError( + "This worktree is shared by another thread, so its folder cannot be renamed here.", + ); } + nextWorktreePath = yield* nextManagedWorktreePath(thread.worktreePath, input.newBranch); + } - const project = yield* projectService.getShell(thread.projectId).pipe( - Effect.map(Option.getOrUndefined), - Effect.mapError((cause) => renameThreadBranchError("Could not load the project.", cause)), - ); - if (!project) { - return yield* Effect.fail(renameThreadBranchError("The project no longer exists.")); - } - - const currentCwd = thread.worktreePath ?? project.workspaceRoot; - if (path.resolve(input.cwd) !== path.resolve(currentCwd)) { - return yield* Effect.fail( - renameThreadBranchError("The thread workspace changed. Refresh and try again."), + if (input.renameWorktreeFolder) { + // Coder: upstream detaches provider sessions when the worktree path changes, but the + // folder moves first here, so an active run must finish before its cwd moves. + if (thread.activeRunId !== null) { + return yield* renameThreadBranchError( + "Stop the running turn before renaming the worktree folder.", ); } - const localStatus = yield* gitWorkflow - .localStatus({ cwd: currentCwd }) + yield* terminalManager + .close({ threadId: thread.id }) .pipe( Effect.mapError((cause) => - renameThreadBranchError("Could not read the checked-out branch.", cause), - ), - ); - if (localStatus.refName !== input.expectedBranch) { - return yield* Effect.fail( - renameThreadBranchError( - `The checkout is on ${localStatus.refName ?? "a detached HEAD"}, not ${input.expectedBranch}. Refresh and try again.`, + renameThreadBranchError("Could not close the thread terminals.", cause), ), ); - } + } - let nextWorktreePath = thread.worktreePath; - if (input.renameWorktreeFolder) { - if (!thread.worktreePath) { - return yield* Effect.fail( - renameThreadBranchError("This thread does not have a dedicated worktree folder."), - ); - } - const shell = yield* threadManagement - .getShellSnapshot({ location: "active" }) - .pipe( - Effect.mapError((cause) => - renameThreadBranchError("Could not inspect worktree ownership.", cause), - ), - ); + let worktreeMoved = false; + const mutate = renameBranchWithCompensation({ + rename: gitWorkflow.renameBranch({ + cwd: currentCwd, + oldBranch: input.expectedBranch, + newBranch: input.newBranch, + }), + afterRename: Effect.fn("afterRename")(function* () { if ( - shell.threads.some( - (other) => other.id !== thread.id && other.worktreePath === thread.worktreePath, - ) + input.renameWorktreeFolder && + thread.worktreePath && + nextWorktreePath && + thread.worktreePath !== nextWorktreePath ) { - return yield* Effect.fail( - renameThreadBranchError( - "This worktree is shared by another thread, so its folder cannot be renamed here.", - ), - ); + yield* gitWorkflow.moveWorktree({ + cwd: project.workspaceRoot, + oldPath: thread.worktreePath, + newPath: nextWorktreePath, + }); + worktreeMoved = true; } - nextWorktreePath = yield* nextManagedWorktreePath(thread.worktreePath, input.newBranch); - } - if (input.renameWorktreeFolder) { - // Coder: upstream detaches provider sessions when the worktree path changes, but the - // folder moves first here, so an active run must finish before its cwd moves. - if (thread.activeRunId !== null) { - return yield* Effect.fail( - renameThreadBranchError("Stop the running turn before renaming the worktree folder."), - ); + yield* orchestrationEngine.dispatch({ + type: "thread.metadata.update", + commandId: yield* serverCommandId("thread-branch-rename"), + threadId: thread.id, + branch: input.newBranch, + worktreePath: nextWorktreePath, + expectedWorktreePath: thread.worktreePath, + }); + }), + rollback: Effect.fn("rollback")(function* () { + if (worktreeMoved && thread.worktreePath && nextWorktreePath) { + yield* gitWorkflow + .moveWorktree({ + cwd: project.workspaceRoot, + oldPath: nextWorktreePath, + newPath: thread.worktreePath, + }) + .pipe(Effect.ignore); } - yield* terminalManager - .close({ threadId: thread.id }) - .pipe( - Effect.mapError((cause) => - renameThreadBranchError("Could not close the thread terminals.", cause), - ), - ); - } - - let worktreeMoved = false; - const mutate = renameBranchWithCompensation({ - rename: gitWorkflow.renameBranch({ - cwd: currentCwd, - oldBranch: input.expectedBranch, - newBranch: input.newBranch, - }), - afterRename: () => - Effect.gen(function* () { - if ( - input.renameWorktreeFolder && - thread.worktreePath && - nextWorktreePath && - thread.worktreePath !== nextWorktreePath - ) { - yield* gitWorkflow.moveWorktree({ - cwd: project.workspaceRoot, - oldPath: thread.worktreePath, - newPath: nextWorktreePath, - }); - worktreeMoved = true; - } - - yield* orchestrationEngine.dispatch({ - type: "thread.metadata.update", - commandId: yield* serverCommandId("thread-branch-rename"), - threadId: thread.id, - branch: input.newBranch, - worktreePath: nextWorktreePath, - expectedWorktreePath: thread.worktreePath, - }); - }), - rollback: () => - Effect.gen(function* () { - if (worktreeMoved && thread.worktreePath && nextWorktreePath) { - yield* gitWorkflow - .moveWorktree({ - cwd: project.workspaceRoot, - oldPath: nextWorktreePath, - newPath: thread.worktreePath, - }) - .pipe(Effect.ignore); - } - yield* gitWorkflow - .renameBranch({ - cwd: project.workspaceRoot, - oldBranch: input.newBranch, - newBranch: input.expectedBranch, - }) - .pipe(Effect.ignore); - }), - }); + yield* gitWorkflow + .renameBranch({ + cwd: project.workspaceRoot, + oldBranch: input.newBranch, + newBranch: input.expectedBranch, + }) + .pipe(Effect.ignore); + }), + }); - yield* mutate.pipe( - Effect.catchCause((cause) => - Effect.fail( - renameThreadBranchError( - "Could not rename the branch and worktree.", - Cause.squash(cause), - ), - ), + yield* mutate.pipe( + Effect.catchCause((cause) => + Effect.fail( + renameThreadBranchError("Could not rename the branch and worktree.", Cause.squash(cause)), ), - ); + ), + ); - const refreshCwd = nextWorktreePath ?? project.workspaceRoot; - yield* Effect.all([refreshGitStatus(refreshCwd), refreshGitStatus(project.workspaceRoot)], { - concurrency: "unbounded", - }).pipe(Effect.ignore); - return { branch: input.newBranch, worktreePath: nextWorktreePath }; - }); - return { - // Coder: upstream's HTTP bounded snapshot and history routes over helper stdio. - [WS_METHODS.orchestrationGetThreadBoundedSnapshot]: (input) => - getThreadBoundedSnapshot(input.threadId, input.deferWork === true), - [WS_METHODS.orchestrationGetThreadHistoryPage]: (input) => getThreadHistoryPage(input), - [WS_METHODS.orchestrationGetThreadTranscript]: (input) => getThreadTranscript(input.threadId), - // Coder: serve GitLab diffs over stdio instead of environment HTTP. - [WS_METHODS.pullRequestsDiff]: (input) => pullRequests.diff(input), - // Coder: read fixed bounded project metadata in the workspace. - [WS_METHODS.projectsGetConfig]: ({ projectId }) => - Effect.gen(function* () { - const project = yield* projectService.getShell(projectId); - if (Option.isNone(project)) return { status: "unavailable" as const, file: null }; - return readProjectConfig(project.value.workspaceRoot); - }).pipe(Effect.orElseSucceed(() => ({ status: "unavailable" as const, file: null }))), - // Coder: the gateway's loopback asset and upload routes, answered by upstream's routes in - // process; an upload's body arrives in chunks. - [WS_METHODS.coderServeAsset]: (input) => assets.serve(input), - [WS_METHODS.coderWriteRequestBody]: (input) => assets.writeRequestBody(input), - [WS_METHODS.coderWorkspaceResourceUsage]: () => - readCoderWorkspaceResourceUsage.pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - ), - // Coder: subscribe to non-mutating local branch status. - [WS_METHODS.subscribeVcsRefStatus]: ({ cwd }) => vcsStatusBroadcaster.streamRefStatus(cwd), - // Coder: rename the branch and managed worktree together. - [WS_METHODS.vcsRenameThreadBranch]: (input) => - renameThreadBranch(input).pipe( - Effect.catch((cause) => - Effect.fail( - cause instanceof VcsRenameThreadBranchError - ? cause - : renameThreadBranchError("Could not rename the branch and worktree.", cause), - ), - ), - ), - // Coder: expand oversized review files through bounded stdio chunks. - [WS_METHODS.reviewOpenDiffFileContents]: (input) => review.openDiffFileContents(input), - // Coder: read bounded chunks with a revision check. - [WS_METHODS.reviewReadDiffFileChunk]: (input) => review.readDiffFileChunk(input), - } satisfies Partial; + const refreshCwd = nextWorktreePath ?? project.workspaceRoot; + yield* Effect.all([refreshGitStatus(refreshCwd), refreshGitStatus(project.workspaceRoot)], { + concurrency: "unbounded", + }).pipe(Effect.ignore); + return { branch: input.newBranch, worktreePath: nextWorktreePath }; }); + return { + // Coder: upstream's HTTP bounded snapshot and history routes over helper stdio. + [WS_METHODS.orchestrationGetThreadBoundedSnapshot]: (input) => + getThreadBoundedSnapshot(input.threadId, input.deferWork === true), + [WS_METHODS.orchestrationGetThreadHistoryPage]: (input) => getThreadHistoryPage(input), + [WS_METHODS.orchestrationGetThreadTranscript]: (input) => getThreadTranscript(input.threadId), + // Coder: serve GitLab diffs over stdio instead of environment HTTP. + [WS_METHODS.pullRequestsDiff]: (input) => pullRequests.diff(input), + // Coder: read fixed bounded project metadata in the workspace. + [WS_METHODS.projectsGetConfig]: ({ projectId }) => + Effect.gen(function* () { + const project = yield* projectService.getShell(projectId); + if (Option.isNone(project)) return { status: "unavailable" as const, file: null }; + return readProjectConfig(project.value.workspaceRoot); + }).pipe(Effect.orElseSucceed(() => ({ status: "unavailable" as const, file: null }))), + // Coder: the gateway's loopback asset and upload routes, answered by upstream's routes in + // process; an upload's body arrives in chunks. + [WS_METHODS.coderServeAsset]: (input) => assets.serve(input), + [WS_METHODS.coderWriteRequestBody]: (input) => assets.writeRequestBody(input), + [WS_METHODS.coderWorkspaceResourceUsage]: () => + readCoderWorkspaceResourceUsage.pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + ), + // Coder: subscribe to non-mutating local branch status. + [WS_METHODS.subscribeVcsRefStatus]: ({ cwd }) => vcsStatusBroadcaster.streamRefStatus(cwd), + // Coder: rename the branch and managed worktree together. + [WS_METHODS.vcsRenameThreadBranch]: (input) => + renameThreadBranch(input).pipe( + Effect.mapError((cause) => + Schema.is(VcsRenameThreadBranchError)(cause) + ? cause + : renameThreadBranchError("Could not rename the branch and worktree.", cause), + ), + ), + // Coder: expand oversized review files through bounded stdio chunks. + [WS_METHODS.reviewOpenDiffFileContents]: (input) => review.openDiffFileContents(input), + // Coder: read bounded chunks with a revision check. + [WS_METHODS.reviewReadDiffFileChunk]: (input) => review.readDiffFileChunk(input), + } satisfies Partial; +}); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index d7103d89fd18c..43ccc7ec4d3e8 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -61,10 +61,7 @@ export const deriveServerPaths = Effect.fn(function* (baseDir: string) { export const ensureServerDirectories = Effect.fn(function* (paths: ServerDerivedPaths) { const fileSystem = yield* FileSystem.FileSystem; - yield* Effect.all( - /** - * ServerConfig - Service tag for server runtime configuration. - */ + yield* Effect.forEach( [ paths.stateDir, paths.logsDir, @@ -72,8 +69,9 @@ export const ensureServerDirectories = Effect.fn(function* (paths: ServerDerived paths.providerStatusCacheDir, paths.worktreesDir, paths.attachmentsDir, - ].map((directory) => fileSystem.makeDirectory(directory, { recursive: true })), - { concurrency: "unbounded" }, + ], + (directory) => fileSystem.makeDirectory(directory, { recursive: true }), + { concurrency: "unbounded", discard: true }, ); // Logs, traces and the database are written with the process umask; keep them owner-only. yield* fileSystem diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 281b11bc438fe..33c5543e73138 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -6,6 +6,7 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Context from "effect/Context"; @@ -19,6 +20,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/process"; import { expect } from "vite-plus/test"; import type { @@ -634,7 +636,7 @@ function makeManager(input?: { const args = command._tag === "StandardCommand" && NodePath.parse(command.command).name.toLowerCase() === "git" - ? command.args + ? command.args.map(String) : null; if (args !== null) gitCommands?.push(args.join(" ")); if (args === null || gitSpawnGate === undefined || !gitSpawnGate.matches(args)) { @@ -652,7 +654,7 @@ function makeManager(input?: { ); }); }), - ).pipe(Layer.provide(NodeServices.layer)) + ).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))) : Layer.empty; const gitVcsDriver = input?.gitConfigReads ? Layer.effect( @@ -673,7 +675,7 @@ function makeManager(input?: { const layerVcsDriver = (watchesGitSpawns ? Layer.fresh(gitVcsDriver) : gitVcsDriver).pipe( Layer.provide(layerGitDriverSpawner), Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(layerServerConfig), ); const layerSourceControlRegistry = Layer.effect( @@ -715,7 +717,7 @@ function makeManager(input?: { ).pipe( Layer.provideMerge(layerSourceControlRegistry), Layer.provideMerge(layerVcsDriver), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const layerStores = Layer.merge(ProjectionStore.layer, ProjectStore.layer).pipe( Layer.provideMerge(SqlitePersistence.layerMemory), @@ -739,7 +741,7 @@ const asThreadId = (threadId: string) => threadId as ThreadId; const layerGitManagerTest = GitVcsDriver.layer.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); it.layer(layerGitManagerTest)("GitManager", (it) => { @@ -1079,6 +1081,78 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("a settled branch PR is rechecked only when the branch moves or every 15 minutes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/settled"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/settled"]); + const pullRequest = (number: number, state: "open" | "merged"): FakeChangeRequest => ({ + number, + title: "Settled", + url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`, + baseRefName: "main", + headRefName: "feature/settled", + state, + updatedAt: `2026-04-0${number}T15:00:00Z`, + }); + const gitCommands: string[] = []; + const { manager } = yield* makeManager({ + gitCommands, + providerScenario: { + prListSequence: [ + [pullRequest(1, "merged")], + [pullRequest(1, "merged")], + [pullRequest(1, "merged")], + ], + prListOtherwise: [pullRequest(2, "open")], + }, + }); + const lookup = manager.branchPullRequest({ cwd: repoDir, branch: "feature/settled" }); + const freshLookups = () => gitCommands.filter((args) => args === "remote").length; + + expect((yield* lookup)?.state).toBe("merged"); + gitCommands.length = 0; + expect((yield* lookup)?.state).toBe("merged"); + expect((yield* lookup)?.state).toBe("merged"); + expect(gitCommands.map((args) => args.split(" ", 1)[0]).toSorted()).toEqual([ + "config", + "config", + "for-each-ref", + "for-each-ref", + ]); + expect(freshLookups()).toBe(0); + + NodeFS.writeFileSync(NodePath.join(repoDir, "settled.txt"), "new work\n"); + yield* runGit(repoDir, ["add", "settled.txt"]); + yield* runGit(repoDir, ["commit", "-m", "New work"]); + gitCommands.length = 0; + expect((yield* lookup)?.state).toBe("merged"); + expect(freshLookups()).toBe(1); + gitCommands.length = 0; + yield* lookup; + expect(freshLookups()).toBe(0); + + // Remembered git state expires after 5 minutes; that rereads git without a recheck. + yield* TestClock.adjust("10 minutes"); + gitCommands.length = 0; + expect((yield* lookup)?.state).toBe("merged"); + expect(freshLookups()).toBe(1); + + yield* TestClock.adjust("5 minutes"); + gitCommands.length = 0; + expect((yield* lookup)?.state).toBe("merged"); + expect(freshLookups()).toBe(2); + + // A new PR from the reused branch shows on the next host query. + yield* TestClock.adjust("5 minutes"); + expect((yield* lookup)?.number).toBe(2); + }), + ); + it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1777,6 +1851,244 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + function squashMergeIntoRemoteMain(repoDir: string, branch: string) { + return Effect.gen(function* () { + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", branch]); + NodeFS.writeFileSync(NodePath.join(repoDir, "squash.txt"), "one\ntwo\n"); + yield* runGit(repoDir, ["add", "squash.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature work"]); + yield* runGit(repoDir, ["push", "-u", "origin", branch]); + // The host squash-merges: a new commit on main with the branch's tree, not the branch's commit. + const squash = yield* runGit(repoDir, [ + "commit-tree", + "HEAD^{tree}", + "-p", + "origin/main", + "-m", + "Feature work (#217)", + ]); + yield* runGit(repoDir, ["push", "origin", `${squash.stdout.trim()}:refs/heads/main`]); + return (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + }); + } + + const squashMergedPr = (branch: string, headSha: string): FakeChangeRequest => ({ + number: 217, + title: "Feature work", + url: "https://github.com/pingdotgg/t3code/pull/217", + baseRefName: "main", + headRefName: branch, + headSha, + state: "merged", + mergedAt: "2026-04-02T15:00:00Z", + updatedAt: "2026-04-02T15:00:00Z", + }); + + it.effect("status reports nothing to ship after a squash merge deleted the remote branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + const branch = "feature/squash-deleted"; + const headSha = yield* squashMergeIntoRemoteMain(repoDir, branch); + yield* runGit(repoDir, ["push", "origin", "--delete", branch]); + yield* runGit(repoDir, ["fetch", "--prune", "origin"]); + + const { manager } = yield* makeManager({ + providerScenario: { prListSequence: [[squashMergedPr(branch, headSha)]] }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr?.state).toBe("merged"); + expect(status.hasUpstream).toBe(false); + expect({ + aheadCount: status.aheadCount, + aheadOfDefaultCount: status.aheadOfDefaultCount, + }).toEqual({ aheadCount: 0, aheadOfDefaultCount: 0 }); + }), + ); + + it.effect("status reports nothing to ship after a squash merge kept the remote branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + const branch = "feature/squash-kept"; + const headSha = yield* squashMergeIntoRemoteMain(repoDir, branch); + yield* runGit(repoDir, ["fetch", "--prune", "origin"]); + + const { manager } = yield* makeManager({ + providerScenario: { prListSequence: [[squashMergedPr(branch, headSha)]] }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr?.state).toBe("merged"); + expect(status.hasUpstream).toBe(true); + expect({ + aheadCount: status.aheadCount, + aheadOfDefaultCount: status.aheadOfDefaultCount, + }).toEqual({ aheadCount: 0, aheadOfDefaultCount: 0 }); + }), + ); + + it.effect("status still reports work to ship for a commit added after a squash merge", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + const branch = "feature/squash-follow-up"; + const headSha = yield* squashMergeIntoRemoteMain(repoDir, branch); + NodeFS.writeFileSync(NodePath.join(repoDir, "follow-up.txt"), "after merge\n"); + yield* runGit(repoDir, ["add", "follow-up.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Follow-up work"]); + yield* runGit(repoDir, ["fetch", "--prune", "origin"]); + + const { manager } = yield* makeManager({ + providerScenario: { prListSequence: [[squashMergedPr(branch, headSha)]] }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr?.state).toBe("merged"); + expect(status.aheadCount).toBe(1); + expect(status.aheadOfDefaultCount).toBe(2); + }), + ); + + it.effect("status still reports work against main after a merge into another base", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const initialSha = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + const branch = "feature/release-only"; + const headSha = yield* squashMergeIntoRemoteMain(repoDir, branch); + // Move the merge to release and leave main without it. + const squashSha = (yield* runGit(repoDir, ["rev-parse", "origin/main"])).stdout.trim(); + yield* runGit(repoDir, ["push", "origin", `${squashSha}:refs/heads/release`]); + yield* runGit(repoDir, ["push", "--force", "origin", `${initialSha}:refs/heads/main`]); + yield* runGit(repoDir, ["fetch", "origin"]); + + const { manager } = yield* makeManager({ + providerScenario: { + prListSequence: [[{ ...squashMergedPr(branch, headSha), baseRefName: "release" }]], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr?.state).toBe("merged"); + expect(status.aheadOfDefaultCount).toBe(1); + }), + ); + + it.effect("status keeps a squash-merged branch shipped when a later PR lookup fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + const branch = "feature/squash-lookup-fails"; + const headSha = yield* squashMergeIntoRemoteMain(repoDir, branch); + yield* runGit(repoDir, ["push", "origin", "--delete", branch]); + yield* runGit(repoDir, ["fetch", "--prune", "origin"]); + + const { manager } = yield* makeManager({ + providerScenario: { + prListSequence: [[squashMergedPr(branch, headSha)]], + failWith: "Rate limited", + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + yield* manager.invalidateStatus(repoDir); + const second = yield* manager.status({ cwd: repoDir }); + + expect(first.aheadOfDefaultCount).toBe(0); + expect(second.pr?.state).toBe("merged"); + expect({ + aheadCount: second.aheadCount, + aheadOfDefaultCount: second.aheadOfDefaultCount, + }).toEqual({ aheadCount: 0, aheadOfDefaultCount: 0 }); + }), + ); + + it.effect("status keeps real divergence for a squash-merged branch that tracks its base", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + const branch = "feature/squash-tracks-main"; + const headSha = yield* squashMergeIntoRemoteMain(repoDir, branch); + yield* runGit(repoDir, ["fetch", "origin"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "origin/main"]); + + const { manager } = yield* makeManager({ + providerScenario: { prListSequence: [[squashMergedPr(branch, headSha)]] }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr?.state).toBe("merged"); + expect({ aheadCount: status.aheadCount, behindCount: status.behindCount }).toEqual({ + aheadCount: 1, + behindCount: 1, + }); + }), + ); + + it.effect("status reports nothing to ship after a squash merge into a non-origin default", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["branch", "-m", "develop"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "upstream", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "upstream", "develop"]); + yield* runGit(repoDir, ["remote", "set-head", "upstream", "develop"]); + const branch = "feature/squash-develop"; + yield* runGit(repoDir, ["checkout", "-b", branch]); + NodeFS.writeFileSync(NodePath.join(repoDir, "squash.txt"), "one\ntwo\n"); + yield* runGit(repoDir, ["add", "squash.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature work"]); + yield* runGit(repoDir, ["push", "-u", "upstream", branch]); + const headSha = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + const squash = yield* runGit(repoDir, [ + "commit-tree", + "HEAD^{tree}", + "-p", + "upstream/develop", + "-m", + "Feature work (#217)", + ]); + yield* runGit(repoDir, ["push", "upstream", `${squash.stdout.trim()}:refs/heads/develop`]); + yield* runGit(repoDir, ["fetch", "upstream"]); + + const { manager } = yield* makeManager({ + providerScenario: { + prListSequence: [[{ ...squashMergedPr(branch, headSha), baseRefName: "develop" }]], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr?.state).toBe("merged"); + expect({ + aheadCount: status.aheadCount, + aheadOfDefaultCount: status.aheadOfDefaultCount, + }).toEqual({ aheadCount: 0, aheadOfDefaultCount: 0 }); + }), + ); + // Coder: only GitLab's definition ships, so other hosts' change request URLs name no // repository. it.each([ @@ -2553,6 +2865,46 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("keeps signature output out of recent commit subjects", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const signingKey = NodePath.join(yield* makeTempDir("t3code-git-signing-key-"), "key"); + NodeChildProcess.execFileSync("ssh-keygen", [ + "-q", + "-t", + "ed25519", + "-N", + "", + "-f", + signingKey, + ]); + yield* runGit(repoDir, ["config", "gpg.format", "ssh"]); + // A global signer such as 1Password's cannot use the fixture's generated key. + yield* runGit(repoDir, ["config", "gpg.ssh.program", "ssh-keygen"]); + yield* runGit(repoDir, ["config", "user.signingkey", `${signingKey}.pub`]); + yield* runGit(repoDir, ["config", "log.showSignature", "true"]); + yield* runGit(repoDir, ["commit", "--amend", "-S", "--no-edit"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello again\n"); + let commitInstructions: string | undefined; + + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: (input) => { + commitInstructions = input.policy?.commitInstructions; + return Effect.succeed({ subject: "Update readme", body: "" }); + }, + }, + }); + + yield* runStackedAction(manager, { cwd: repoDir, action: "commit" }); + + expect(commitInstructions).toMatch( + /Recent commit subjects from this repository:\nInitial commit$/, + ); + }), + ); + it.effect("falls back when the dedicated source control writer is unavailable", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 9c973b7cb7475..3356ea8cad65c 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1,8 +1,9 @@ import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as ByteSize from "effect/ByteSize"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -171,6 +172,7 @@ const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; const PR_LOOKUP_GIT_STATE_TTL = Duration.minutes(5); +const SETTLED_PR_LOOKUP_RECHECK_INTERVAL = Duration.minutes(15); const PR_THREAD_CONTEXT_BYTES = 16_000; const PR_THREAD_COMMAND_OUTPUT_CHARS = 2_000; const isSourceControlProviderError = Schema.is(SourceControlProviderError); @@ -653,7 +655,7 @@ export const make = Effect.gen(function* () { const textGeneration = yield* TextGeneration.TextGeneration; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -791,7 +793,7 @@ export const make = Effect.gen(function* () { .execute({ operation: "GitManager.readRecentCommitSubjects", cwd, - args: ["log", "-n", "20", "--no-merges", "--pretty=format:%s"], + args: ["log", "-n", "20", "--no-merges", "--no-show-signature", "--pretty=format:%s"], }) .pipe( Effect.map((result) => @@ -854,7 +856,7 @@ export const make = Effect.gen(function* () { } }); const randomUUIDv4 = (cwd: string) => - crypto.randomUUIDv4().pipe( + random.uuidV4().pipe( Effect.mapError( (cause) => new GitManagerError({ @@ -1098,7 +1100,11 @@ export const make = Effect.gen(function* () { ), ), ); - const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; + const tempDir = yield* Config.String("TMPDIR").pipe( + Config.orElse(() => Config.String("TEMP")), + Config.orElse(() => Config.String("TMP")), + Config.withDefault("/tmp"), + ); const canonicalizeExistingPath = (value: string) => fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); const normalizeStatusCacheKey = canonicalizeExistingPath; @@ -1259,6 +1265,7 @@ export const make = Effect.gen(function* () { const getPrLookup = (key: string) => detachStackFrame(Cache.get(prLookupCache, key)); interface LastKnownPr { readonly pr: ReturnType | null; + readonly headSha: string | null; readonly upstreamRef: string | null; readonly headBranch: string; readonly remoteName: string | null; @@ -1280,7 +1287,7 @@ export const make = Effect.gen(function* () { const resolveLastKnownPr = ( branchKey: string, current: Pick, - ): ReturnType | null => { + ): LastKnownPr | null => { const lastKnown = lastKnownPrByBranchKey.get(branchKey); if (!lastKnown) return null; if (lastKnown.headBranch !== current.headBranch) { @@ -1289,7 +1296,7 @@ export const make = Effect.gen(function* () { // Config read failures become null; require both remote URLs before treating a mismatch as real. if (lastKnown.headRemoteUrlKey !== null && current.headRemoteUrlKey !== null) { - return lastKnown.headRemoteUrlKey === current.headRemoteUrlKey ? lastKnown.pr : null; + return lastKnown.headRemoteUrlKey === current.headRemoteUrlKey ? lastKnown : null; } if ( @@ -1298,9 +1305,9 @@ export const make = Effect.gen(function* () { lastKnown.remoteName !== null && current.remoteName !== null ) { - return lastKnown.remoteName === current.remoteName ? lastKnown.pr : null; + return lastKnown.remoteName === current.remoteName ? lastKnown : null; } - return lastKnown.pr; + return lastKnown; }; const lookupStatusPr = Effect.fn("lookupStatusPr")(function* ( cwd: string, @@ -1325,17 +1332,18 @@ export const make = Effect.gen(function* () { } return yield* getPrLookup(cacheKey).pipe( Effect.map(({ latest, headContext }) => { - if (!latest) return { pr: null, headContext }; + if (!latest) return { pr: null, headSha: null, headContext }; // Merged/closed matches are usually reverse-merge history, not the thread's PR context. if (details.isDefaultBranch && latest.state !== "open") { - return { pr: null, headContext }; + return { pr: null, headSha: null, headContext }; } - return { pr: toStatusPr(latest), headContext }; + return { pr: toStatusPr(latest), headSha: latest.headSha ?? null, headContext }; }), - Effect.tap(({ pr, headContext }) => + Effect.tap(({ pr, headSha, headContext }) => Effect.sync(() => rememberLastKnownPr(branchKey, { pr, + headSha, upstreamRef: details.upstreamRef, headBranch: headContext.headBranch, remoteName: headContext.remoteName, @@ -1343,7 +1351,7 @@ export const make = Effect.gen(function* () { }), ), ), - Effect.map(({ pr }) => pr), + Effect.map(({ pr, headSha }) => ({ pr, headSha })), Effect.catch((error) => { const fallback = lastKnownPrByBranchKey.get(branchKey)?.pr ? resolveLookupHeadContext(cwd, details).pipe( @@ -1375,6 +1383,10 @@ export const make = Effect.gen(function* () { : {}), }), Effect.andThen(fallback), + Effect.map((lastKnown) => ({ + pr: lastKnown?.pr ?? null, + headSha: lastKnown?.headSha ?? null, + })), ); }), ); @@ -1390,7 +1402,7 @@ export const make = Effect.gen(function* () { return null; } - const pr = + const { pr, headSha } = details.branch !== null ? yield* lookupStatusPr( cwd, @@ -1402,13 +1414,35 @@ export const make = Effect.gen(function* () { }, options?.refreshMissingPullRequest, ) - : null; + : { pr: null, headSha: null }; + // Squash and rebase merges land new commits, so HEAD never becomes reachable from the base. + const shippedByMergedPr = + pr?.state === "merged" && + headSha !== null && + (yield* Effect.gen(function* () { + const defaultBranch = + details.defaultBranch ?? + (yield* gitCore.resolvePrimaryRemoteName(cwd).pipe( + Effect.flatMap((remoteName) => gitCore.resolveDefaultBranchName(cwd, remoteName)), + Effect.orElseSucceed(() => null), + )); + const mergedIntoDefault = + defaultBranch === null + ? pr.baseRef === "main" || pr.baseRef === "master" + : pr.baseRef === defaultBranch; + if (!mergedIntoDefault) return false; + return yield* gitCore.resolveCommit({ cwd, revision: "HEAD" }).pipe( + Effect.map(({ commitSha }) => commitSha === headSha), + Effect.orElseSucceed(() => false), + ); + })); return { hasUpstream: details.hasUpstream, - aheadCount: details.aheadCount, + // A real upstream delta stays; only the no-upstream fallback counts against the base. + aheadCount: shippedByMergedPr && !details.hasUpstream ? 0 : details.aheadCount, behindCount: details.behindCount, - aheadOfDefaultCount: details.aheadOfDefaultCount, + aheadOfDefaultCount: shippedByMergedPr ? 0 : details.aheadOfDefaultCount, pr, } satisfies VcsStatusRemoteResult; }); @@ -2426,6 +2460,10 @@ export const make = Effect.gen(function* () { prLookupGitStateByKey.set(entryKey, { readAtMs, value }); return value; }); + const settledPrLookupByBranchKey = new Map< + string, + { readonly url: string; readonly fingerprint: string; readonly confirmedAtMs: number } + >(); const forgetPrLookupGitState = (cwd: string) => normalizeStatusCacheKey(cwd).pipe( Effect.map((cacheCwd) => { @@ -2471,6 +2509,39 @@ export const make = Effect.gen(function* () { }; }, ); + // objectname lets a settled PR result notice new commits on its branch. + const readPrLookupBranchRef = (cacheCwd: string, branch: string, fresh: boolean) => + rememberPrLookupGitState( + cacheCwd, + `branchRef\u0000${branch}`, + fresh, + gitCore + .execute({ + operation: "GitManager.branchPullRequest.branchRef", + cwd: cacheCwd, + args: [ + "for-each-ref", + "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)%00%(objectname)", + `refs/heads/${branch}`, + ], + }) + .pipe(Effect.map((result) => result.stdout)), + ); + const readSettledPrLookupFingerprint = (cacheCwd: string, branch: string) => + Effect.all( + [ + readPrLookupBranchRef(cacheCwd, branch, true), + gitCore + .execute({ + operation: "GitManager.branchPullRequest.remoteUrls", + cwd: cacheCwd, + args: ["config", "--get-regexp", "^remote\\..*\\.url$"], + allowNonZeroExit: true, + }) + .pipe(Effect.map((result) => result.stdout)), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.map((parts) => parts.join("\u0000"))); const lookupBranchPullRequest = Effect.fn("lookupBranchPullRequest")(function* ( cacheCwd: string, branch: string, @@ -2494,20 +2565,7 @@ export const make = Effect.gen(function* () { .filter((remoteName) => remoteName.length > 0); const [firstRemoteName] = remoteNames; if (firstRemoteName === undefined) return null; - const branchRef = yield* remember( - `branchRef\u0000${branch}`, - gitCore - .execute({ - operation: "GitManager.branchPullRequest.branchRef", - cwd: cacheCwd, - args: [ - "for-each-ref", - "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)", - `refs/heads/${branch}`, - ], - }) - .pipe(Effect.map((result) => result.stdout)), - ); + const branchRef = yield* readPrLookupBranchRef(cacheCwd, branch, options.freshGitState); const expectedRefName = `refs/heads/${branch}`; const exactBranch = (branchRef ?? "") .split("\n") @@ -2658,10 +2716,38 @@ export const make = Effect.gen(function* () { freshGitState, }); if (pullRequest === null || pullRequest.state === "open" || freshGitState) return pullRequest; - return yield* lookupBranchPullRequest(cacheCwd, branch, { + // A confirmed closed or merged result stays final until the branch or remotes change, + // with a periodic fresh lookup so a new PR from a reused branch still shows up. + const settledKey = `${cacheCwd}\u0000${branch}`; + const now = yield* Clock.currentTimeMillis; + const settled = settledPrLookupByBranchKey.get(settledKey); + // Read before any fresh lookup, so a change made during it forces another one. + const fingerprint = yield* readSettledPrLookupFingerprint(cacheCwd, branch); + if ( + settled !== undefined && + settled.url === pullRequest.url && + settled.fingerprint === fingerprint && + now - settled.confirmedAtMs < Duration.toMillis(SETTLED_PR_LOOKUP_RECHECK_INTERVAL) + ) { + return pullRequest; + } + const confirmed = yield* lookupBranchPullRequest(cacheCwd, branch, { refresh: false, freshGitState: true, }); + settledPrLookupByBranchKey.delete(settledKey); + if (confirmed !== null && confirmed.state !== "open") { + if (settledPrLookupByBranchKey.size >= PR_LOOKUP_CACHE_CAPACITY) { + const oldestKey = settledPrLookupByBranchKey.keys().next().value; + if (oldestKey !== undefined) settledPrLookupByBranchKey.delete(oldestKey); + } + settledPrLookupByBranchKey.set(settledKey, { + url: confirmed.url, + fingerprint, + confirmedAtMs: now, + }); + } + return confirmed; }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index b5f82fbc78d36..ddac6412fe23e 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -2,6 +2,7 @@ import { assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as FileSystem from "effect/FileSystem"; @@ -12,7 +13,7 @@ import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -it.layer(NodeServices.layer)("GitWorkflowService", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("GitWorkflowService", (it) => { it.effect("finds a name among the refs of every namespace", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -47,10 +48,10 @@ it.layer(NodeServices.layer)("GitWorkflowService", (it) => { Layer.provideMerge(VcsProcess.layer), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3-ref-named-" }).pipe( - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), ), diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index b6ca7d8c746a6..93f4375b11fdb 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -18,6 +18,7 @@ import { storeAttachmentUpload, validateAttachmentUploadToken, } from "./assets/AttachmentUpload.ts"; +import { requestUrl } from "./requestUrl.ts"; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; // Opaque origin blocks cookies, storage and API access while allowing scripts. @@ -216,7 +217,7 @@ export const layerAssetRoute = HttpRouter.add( `${ASSET_ROUTE_PREFIX}/*`, Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; - const url = HttpServerRequest.toURL(request); + const url = requestUrl(request); if (Option.isNone(url)) { return HttpServerResponse.text("Bad Request", { status: 400 }); } @@ -274,7 +275,7 @@ export const layerAttachmentUploadRoute = HttpRouter.add( `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`, Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; - const url = HttpServerRequest.toURL(request); + const url = requestUrl(request); if (Option.isNone(url)) { return HttpServerResponse.text("Bad Request", { status: 400 }); } diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 7791134d7cf89..624fcc1241b35 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -12,7 +12,7 @@ import { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; -import { formatThreadLink } from "@t3tools/shared/threadLinks"; +import { formatThreadLink, hostedThreadLink } from "@t3tools/shared/threadLinks"; const ALL_MCP_CAPABILITIES = [ "preview", @@ -48,8 +48,8 @@ export interface McpClientCaller { * `clientModeCeiling`). */ readonly narrowedTo?: ClientModes | undefined; - /** The origin an outside client reached; its thread links open the web app there. */ - readonly webOrigin?: string | undefined; + /** An outside client's thread links go through this hosted app, as Copy thread's do. */ + readonly hostedAppUrl?: string | undefined; } export interface ClientModes { @@ -105,16 +105,15 @@ export interface McpInvocationScope { /** * T3 Code renders `t3-thread://` links as thread chips. Other MCP clients cannot open them, so - * they get a web URL on the origin they reached instead. + * they get the hosted link Copy thread copies, which opens the thread in either app or a browser. */ export const threadLinkFor = ( scope: Pick, threadId: ThreadId, ): string => { - const origin = scope.client?.webOrigin; - return origin === undefined - ? formatThreadLink({ environmentId: scope.environmentId, threadId }) - : `${origin}/${encodeURIComponent(scope.environmentId)}/${encodeURIComponent(threadId)}`; + const hostedAppUrl = scope.client?.hostedAppUrl; + const ref = { environmentId: scope.environmentId, threadId }; + return hostedAppUrl === undefined ? formatThreadLink(ref) : hostedThreadLink(ref, hostedAppUrl); }; export class McpInvocationContext extends Context.Service< diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 8920e592c57d7..be85cccf67da4 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -1,22 +1,19 @@ -import { execFile } from "node:child_process"; -import { access, mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; - +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as TestClock from "effect/testing/TestClock"; import * as CoderEnvironment from "../coderEnvironment.ts"; import * as T3ToolDispatch from "./bridge/T3ToolDispatch.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; -const exec = promisify(execFile); const THREAD_ID = ThreadId.make("thread/with spaces 'and' quotes"); const OTHER_THREAD_ID = ThreadId.make("thread-other"); const CODEX = ProviderInstanceId.make("codex"); @@ -43,12 +40,15 @@ const services = Layer.mergeAll( ), ).pipe(Layer.provide(T3ToolDispatch.layer)), NodeServices.layer, + NodeCrypto.layer, ); const harness = Effect.fn(function* (options: { readonly livenessWindowMs?: number } = {}) { - const parent = yield* Effect.acquireRelease( - Effect.promise(() => mkdtemp(join(tmpdir(), "t3-mcp-registry-"))), - (path) => Effect.promise(() => rm(path, { recursive: true, force: true })), + const parent = yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => + fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-registry-" }), + ), + Effect.provide(NodeServices.layer), ); const registry = yield* McpSessionRegistry.__testing .make({ bridgeParent: parent, ...options }) @@ -59,18 +59,20 @@ const harness = Effect.fn(function* (options: { readonly livenessWindowMs?: numb }); const call = (toolCommand: string) => - Effect.promise(() => - exec("/bin/sh", ["-c", `${toolCommand} any_tool '{}'`]).then((result) => - JSON.parse(result.stdout), + ChildProcessSpawner.ChildProcessSpawner.pipe( + Effect.flatMap((spawner) => + spawner.string(ChildProcess.make("/bin/sh", ["-c", `${toolCommand} any_tool '{}'`])), ), + Effect.map((stdout): unknown => JSON.parse(stdout)), + Effect.provide(NodeServices.layer), + Effect.orDie, ); const exists = (path: string) => - Effect.promise(() => - access(path).then( - () => true, - () => false, - ), + FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => fileSystem.exists(path)), + Effect.provide(NodeServices.layer), + Effect.orDie, ); it.live("keeps one hashed bridge directory per thread across session rotation", () => diff --git a/apps/server/src/mcp/McpSessionRegistry.testkit.ts b/apps/server/src/mcp/McpSessionRegistry.testkit.ts index 84676e7ccb1b4..0c7822566f9f2 100644 --- a/apps/server/src/mcp/McpSessionRegistry.testkit.ts +++ b/apps/server/src/mcp/McpSessionRegistry.testkit.ts @@ -19,8 +19,7 @@ export const layer = Layer.succeed( browserToolsAvailable: true, }, }), - // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. - resolve: () => Effect.succeed(undefined), + resolve: () => Effect.undefined, touch: () => Effect.void, revokeProviderSession: () => Effect.void, revokeThread: () => Effect.void, diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 53ac4ac03f1c9..8ca800a0bffe2 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -17,9 +17,10 @@ import * as NodeCrypto from "node:crypto"; import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as CoderEnvironment from "../coderEnvironment.ts"; @@ -79,10 +80,16 @@ const shellWord = (value: string) => const bridgeName = (environmentId: string, threadId: string) => `t3-tools-${NodeCrypto.createHash("sha256").update(`${environmentId}\0${threadId}`).digest("hex").slice(0, 20)}`; +/** Coder: a bridge directory could not be created; the session runs without T3 tools. */ +class T3ToolBridgeUnavailableError extends Schema.TaggedError()( + "T3ToolBridgeUnavailableError", + { cause: Schema.Defect() }, +) {} + const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( options: { readonly livenessWindowMs?: number; readonly bridgeParent?: string } = {}, ) { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const environment = yield* CoderEnvironment.CoderEnvironment; const tools = yield* T3ToolDispatch.T3ToolDispatch; const environmentId = environment.descriptor.environmentId; @@ -125,8 +132,8 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")( function* (request) { const issuedAt = yield* Clock.currentTimeMillis; - const providerSessionId = yield* crypto.randomUUIDv4().pipe(Effect.orDie); - const token = yield* crypto.randomUUIDv4().pipe(Effect.orDie); + const providerSessionId = yield* random.uuidV4(); + const token = yield* random.uuidV4(); const scope: McpInvocationContext.McpThreadInvocationScope = { environmentId, requestNamespace: providerSessionId, @@ -150,20 +157,26 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( }; // Startup binds the toolkits before any provider session opens; until then, no tools. const binding = yield* tools.binding; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + const runSync = Effect.runSyncWith(context); const bridge = binding === undefined ? undefined - : yield* Effect.tryPromise(async () => { - await closingByThread.get(scope.thread.threadId); - return createFileBridge( - (call, signal) => Effect.runPromise(tools.dispatch(scope, call), { signal }), - { - catalog: binding.catalog, - name: bridgeName(environmentId, scope.thread.threadId), - onPublished: (call) => Effect.runSync(tools.published(scope, call)), - ...(options.bridgeParent === undefined ? {} : { parent: options.bridgeParent }), - }, - ); + : yield* Effect.tryPromise({ + try: async () => { + await closingByThread.get(scope.thread.threadId); + return createFileBridge( + (call, signal) => runPromise(tools.dispatch(scope, call), { signal }), + { + catalog: binding.catalog, + name: bridgeName(environmentId, scope.thread.threadId), + onPublished: (call) => runSync(tools.published(scope, call)), + ...(options.bridgeParent === undefined ? {} : { parent: options.bridgeParent }), + }, + ); + }, + catch: (cause) => new T3ToolBridgeUnavailableError({ cause }), }).pipe( // Upstream's issue cannot fail; a turn without T3 tools still runs. Effect.catch(() => @@ -235,9 +248,14 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( issue, resolve, touch, - revokeProviderSession: (providerSessionId) => - revokeWhere((record) => record.scope.thread.providerSessionId === providerSessionId), - revokeThread: (threadId) => revokeWhere((record) => record.scope.thread.threadId === threadId), + revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")( + function* (providerSessionId) { + yield* revokeWhere((record) => record.scope.thread.providerSessionId === providerSessionId); + }, + ), + revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) { + yield* revokeWhere((record) => record.scope.thread.threadId === threadId); + }), revokeAll, }); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 6b637ecc74dc2..8ecdd0e74e9b6 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -8,12 +8,15 @@ import { ProviderInstanceId, RunId, ScheduledTaskId, + SECRET_REQUEST_UNSAVED_NOTE, ThreadId, TurnItemId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "vite-plus/test"; @@ -655,3 +658,173 @@ it("readThread and sendToThread reach threads in other projects", async () => { expect(stopped.code).toBe("parent_not_active"); }).pipe(Effect.provide(layer), Effect.runPromise); }); + +it("requestSecret saves a pasted value to a file without opening a card", async () => { + const projection = { + thread: baseThread({ + threadId: parentThreadId, + title: "Parent", + instanceId: parentInstanceId, + model: "gpt-5.4", + }), + runs: [{ ...makeRun({ id: activeRunId, ordinal: 1, status: "running" }), rootNodeId: taskId }], + visibleTurnItems: [], + runtimeRequests: [], + messages: [], + contextTransfers: [], + subagents: [], + updatedAt: now, + } as unknown as OrchestrationV2ThreadProjection; + const dispatched: Array = []; + const saved: Array<{ + readonly requestNamespace: string; + readonly requestKey: string; + readonly value: string; + }> = []; + const file = "/home/user/.t3/userdata/secrets/files/0123456789abcdef0123456789abcdef"; + + const layer = layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + threadId === parentThreadId + ? Effect.succeed(projection) + : Effect.die(`unexpected thread ${threadId}`), + getThreadShell: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? (projection.thread as unknown as OrchestrationV2ThreadShell) + : null, + ), + dispatch: (command) => Effect.sync(() => void dispatched.push(command) as never), + } satisfies Partial), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + } satisfies Partial), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({ + savePasted: (input) => + Effect.sync(() => { + saved.push({ + requestNamespace: input.requestNamespace, + requestKey: input.requestKey, + value: input.value, + }); + return file; + }), + }), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}), + NodeCrypto.layer, + ), + ), + ); + await Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const result = yield* service.requestSecret(makeScope(), { + label: "Pixel PIN", + reason: "Unlocks the test phone.", + value: "482916", + clientRequestId: "pin-1", + }); + expect(result).toEqual({ status: "saved", file }); + expect(JSON.stringify(result)).not.toContain("482916"); + expect(saved).toEqual([ + { + requestNamespace: "provider-session-mcp-orchestrator-detail", + requestKey: "pin-1", + value: "482916", + }, + ]); + expect(dispatched).toEqual([]); + // Coder: no secret-request metrics, so upstream's saved_pasted count is not checked. + + const asRef = yield* service + .requestSecret(makeScope(), { + label: "Pixel PIN", + reason: "Unlocks the test phone.", + value: "482916", + delivery: "secretRef", + }) + .pipe(Effect.flip); + expect(asRef.code).toBe("invalid_request"); + expect(asRef.message).not.toContain("482916"); + expect(saved).toHaveLength(1); + expect(dispatched).toEqual([]); + }).pipe(Effect.provide(layer), Effect.runPromise); +}); + +it("requestSecret returns the unsaved note for a declined, cancelled or timed-out card", async () => { + const outcomes = ["declined", "cancelled", "pending"] as const; + const results = await Effect.forEach(outcomes, (outcome) => { + const dispatched: Array<{ readonly type: string; readonly turnItemId?: string }> = []; + const projection = () => { + const card = dispatched.find((command) => command.type === "secret_request.record"); + return { + thread: baseThread({ + threadId: parentThreadId, + title: "Parent", + instanceId: parentInstanceId, + model: "gpt-5.4", + }), + runs: [ + { ...makeRun({ id: activeRunId, ordinal: 1, status: "running" }), rootNodeId: taskId }, + ], + visibleTurnItems: [], + turnItems: + card === undefined + ? [] + : [{ type: "secret_request", id: card.turnItemId, secretStatus: outcome }], + runtimeRequests: [], + messages: [], + contextTransfers: [], + subagents: [], + updatedAt: now, + } as unknown as OrchestrationV2ThreadProjection; + }; + const layer = layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.sync(projection), + getThreadShell: () => + Effect.sync(() => projection().thread as unknown as OrchestrationV2ThreadShell), + dispatch: (command) => Effect.sync(() => void dispatched.push(command) as never), + } satisfies Partial), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + } satisfies Partial), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}), + NodeCrypto.layer, + ), + ), + ); + return Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const request = yield* service + .requestSecret(makeScope(), { + label: "Staging password", + reason: "Signs in the QA account.", + delivery: "file", + timeoutMs: 1_000, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("2 seconds"); + return yield* Fiber.join(request); + }).pipe(Effect.provide(Layer.merge(layer, TestClock.layer()))); + }).pipe(Effect.runPromise); + + expect(results).toEqual([ + { status: "declined", note: SECRET_REQUEST_UNSAVED_NOTE.declined }, + { status: "cancelled", note: SECRET_REQUEST_UNSAVED_NOTE.cancelled }, + { status: "timed_out", note: SECRET_REQUEST_UNSAVED_NOTE.timed_out }, + ]); +}); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 10f61f9f395c3..661a82c58106f 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1,7 +1,9 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { CommandId, + ContextTransferId, EnvironmentId, GitCommandError, MessageId, @@ -66,6 +68,7 @@ import type { McpInvocationScope } from "./McpInvocationContext.ts"; import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import { resolveRuntimeMode } from "./delegatedTaskModes.ts"; +import type { ProjectionRecordFilter } from "../orchestration-v2/ProjectionStore.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const decodeTarget = Schema.decodeUnknownEffect(OrchestratorMcpTarget); @@ -82,12 +85,21 @@ describe("OrchestratorMcpService", () => { const childThreadId = ThreadId.make("thread:mcp-ack-child"); const childRunId = RunId.make("run:mcp-ack-child"); const taskId = NodeId.make("node:mcp-ack-task"); + const resultTransferId = ContextTransferId.make("transfer:mcp-ack-result"); const acknowledgementCommandIds = yield* Ref.make>([]); const acknowledgementAttempts = yield* Ref.make(0); const parentProjection = { thread: { id: parentThreadId }, runs: [], - contextTransfers: [], + contextTransfers: [ + { + id: resultTransferId, + type: "subagent_result", + sourceThreadId: childThreadId, + targetThreadId: parentThreadId, + sourcePoint: { runId: childRunId }, + }, + ], subagents: [ { id: taskId, @@ -120,37 +132,57 @@ describe("OrchestratorMcpService", () => { }, ], contextTransfers: [], - messages: [], + messages: [ + { + id: MessageId.make("message:mcp-ack-result"), + runId: childRunId, + role: "assistant", + text: "terminal result", + createdAt: DateTime.makeUnsafe("2026-10-03T10:10:00Z"), + updatedAt: DateTime.makeUnsafe("2026-10-03T10:10:00Z"), + }, + ], + turnItems: [], subagents: [], providerThreads: [], runtimeRequests: [], } as unknown as OrchestrationV2ThreadProjection; let hasNestedWork = true; + let settleAfterTaskRead = false; const layerDependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => - Effect.succeed( - threadId === parentThreadId - ? hasNestedWork - ? { - ...parentProjection, - subagents: parentProjection.subagents.map((task) => ({ - ...task, - result: null, - status: "running" as const, - })), - } - : parentProjection - : hasNestedWork + Effect.sync(() => { + if (threadId !== parentThreadId) { + return hasNestedWork ? { ...childProjection, subagents: [ { ...parentProjection.subagents[0]!, status: "running" as const }, ], } - : childProjection, - ), + : childProjection; + } + const snapshot = hasNestedWork + ? { + ...parentProjection, + contextTransfers: [], + subagents: parentProjection.subagents.map((task) => ({ + ...task, + result: null, + status: "running" as const, + })), + } + : parentProjection; + if (settleAfterTaskRead) { + settleAfterTaskRead = false; + hasNestedWork = false; + } + return snapshot; + }), + delegatedTaskResultPending: () => Effect.succeed(false), dispatch: (command) => Ref.update(acknowledgementCommandIds, (commandIds) => [ ...commandIds, @@ -193,16 +225,21 @@ describe("OrchestratorMcpService", () => { assert.equal(pending.status, "running"); assert.equal(pending.workState, "waiting_for_children"); assert.isNull(pending.summary); + assert.isNull(pending.resultContextTransferId); assert.equal(yield* Ref.get(acknowledgementAttempts), 0); hasNestedWork = false; const error = yield* service.taskStatus(scope, taskId).pipe(Effect.flip); assert.equal(error.code, "orchestration_error"); + // Completion lands after task selection, before the remaining parent/child reads. + hasNestedWork = true; + settleAfterTaskRead = true; const result = yield* service.taskStatus(scope, taskId); assert.equal(result.status, "completed"); assert.equal(result.summary, "terminal result"); assert.equal(result.latestTerminalRunId, childRunId); assert.equal(result.latestTerminalStatus, "completed"); + assert.equal(result.resultContextTransferId, resultTransferId); const commandIds = yield* Ref.get(acknowledgementCommandIds); assert.equal(commandIds.length, 2); assert.notEqual(commandIds[0], commandIds[1]); @@ -256,6 +293,7 @@ describe("OrchestratorMcpService", () => { } as unknown as OrchestrationV2ThreadProjection; const layerDependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), @@ -353,6 +391,7 @@ describe("OrchestratorMcpService", () => { }) as unknown as OrchestrationV2ThreadProjection; const layerDependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection() : childProjection()), @@ -450,10 +489,12 @@ describe("OrchestratorMcpService", () => { } as unknown as OrchestrationV2ThreadProjection; const layerDependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), + listAppOwnedChildThreadIds: () => Effect.succeed([]), dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( Effect.as({} as never), @@ -539,12 +580,14 @@ describe("OrchestratorMcpService", () => { } as unknown as OrchestrationV2ThreadProjection; const layerDependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => threadId === parentThreadId ? Effect.map(Ref.get(status), parentProjection) : Effect.succeed(childProjection), getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), + listAppOwnedChildThreadIds: () => Effect.succeed([]), delegatedTaskResultPending: () => Effect.succeed(false), stopDelegatedTasks: () => Effect.void, // The orchestrator's subagent.stop repair is covered in ThreadStop.test.ts. @@ -639,10 +682,12 @@ describe("OrchestratorMcpService", () => { } as unknown as OrchestrationV2ThreadProjection; const layerDependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), + listAppOwnedChildThreadIds: () => Effect.succeed([]), dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( Effect.andThen(Effect.fail(new Error("simulated stop failure") as never)), @@ -720,10 +765,12 @@ describe("OrchestratorMcpService", () => { } as unknown as OrchestrationV2ThreadProjection; const layerDependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), + listAppOwnedChildThreadIds: () => Effect.succeed([]), dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( Effect.andThen( @@ -807,10 +854,12 @@ describe("OrchestratorMcpService", () => { } as unknown as OrchestrationV2ThreadProjection; const layerDependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), + listAppOwnedChildThreadIds: () => Effect.succeed([]), dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( Effect.andThen( @@ -866,16 +915,25 @@ describe("OrchestratorMcpService", () => { const taskId = NodeId.make("node:mcp-cancel-grandchild-task"); const dispatched = yield* Ref.make>([]); const stoppedBelow = yield* Ref.make(false); - const appOwnedTask = (id: string, threadId: ThreadId, childId: ThreadId) => ({ + const subagentReads = yield* Ref.make>([]); + const appOwnedTask = ( + id: string, + threadId: ThreadId, + childId: ThreadId, + settled?: "settled", + ) => ({ id: NodeId.make(id), threadId, origin: "app_owned", childThreadId: childId, driver: "codex", model: "gpt-5.6-terra", - result: null, - completionDelivery: { state: "pending" }, + status: settled === undefined ? "running" : "completed", + result: settled === undefined ? null : "Done.", + completionDelivery: { state: settled === undefined ? "pending" : "delivered" }, }); + // Older tasks the parent and child dispatched and that have long settled. + const settledThreadId = ThreadId.make("thread:mcp-cancel-grandchild-settled"); const projections = new Map([ [ parentThreadId, @@ -887,7 +945,15 @@ describe("OrchestratorMcpService", () => { }, runs: [], contextTransfers: [], - subagents: [appOwnedTask(taskId, parentThreadId, childThreadId)], + subagents: [ + appOwnedTask( + "node:mcp-cancel-grandchild-old", + parentThreadId, + settledThreadId, + "settled", + ), + appOwnedTask(taskId, parentThreadId, childThreadId), + ], }, ], [ @@ -898,12 +964,30 @@ describe("OrchestratorMcpService", () => { contextTransfers: [], messages: [], subagents: [ + appOwnedTask( + "node:mcp-cancel-grandchild-done", + childThreadId, + settledThreadId, + "settled", + ), appOwnedTask("node:mcp-cancel-grandchild-below", childThreadId, grandchildThreadId), ], providerThreads: [], runtimeRequests: [], }, ], + [ + settledThreadId, + { + thread: { id: settledThreadId }, + runs: [], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + runtimeRequests: [], + }, + ], [ grandchildThreadId, { @@ -919,8 +1003,23 @@ describe("OrchestratorMcpService", () => { ]) as unknown as ReadonlyMap; const layerDependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadRecords: (threadId) => Effect.succeed(projections.get(threadId)!), + getThreadRecords: (threadId, fields, filter) => + ((fields as ReadonlyArray).includes("subagents") + ? Ref.update(subagentReads, (reads) => [...reads, filter]) + : Effect.void + ).pipe(Effect.as(projections.get(threadId)!)), + listAppOwnedChildThreadIds: (threadId) => + Effect.succeed( + projections + .get(threadId)! + .subagents.flatMap((task) => + task.origin === "app_owned" && task.childThreadId !== null + ? [task.childThreadId] + : [], + ), + ), getThreadShell: (threadId) => Effect.succeed( threadId === grandchildThreadId && child.raisedDuringStop !== true @@ -983,6 +1082,12 @@ describe("OrchestratorMcpService", () => { const error = yield* service .cancelTask(scope, { taskId, clientRequestId: "cancel-grandchild-above-modes" }) .pipe(Effect.flip); + // No read decodes every task a thread has dispatched. + for (const filter of yield* Ref.get(subagentReads)) { + assert.isTrue( + filter?.subagentIds !== undefined || filter?.omitSettledSubagents !== undefined, + ); + } return { code: error.code, dispatched: yield* Ref.get(dispatched), @@ -1201,6 +1306,7 @@ describe("OrchestratorMcpService provider resolution", () => { }) as unknown as OrchestrationV2ThreadProjection; const dependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ hasReceipt: () => Effect.succeed(false), assertProviderInstanceEnabled: () => Effect.void, @@ -1287,6 +1393,7 @@ describe("OrchestratorMcpService provider resolution", () => { Effect.gen(function* () { const disabledAntigravityInstanceId = ProviderInstanceId.make("antigravity-alt"); const disabledWithoutAdapterInstanceId = ProviderInstanceId.make("codex-without-adapter"); + const projectDisabledInstanceId = ProviderInstanceId.make("codex-project-off"); const forkOnlyInstanceId = ProviderInstanceId.make("forkOnly"); const forkShadow = yield* buildUnavailableProviderSnapshot({ driverKind: "forkOnly", @@ -1332,19 +1439,47 @@ describe("OrchestratorMcpService provider resolution", () => { model: "gpt-5.4", enabled: false, }), + providerSnapshot({ + instanceId: projectDisabledInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), forkShadow, ]; const layerDependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(parentProjection([])), }), - ServerSettings.layerTest(), + ServerSettings.layerTest({ + providerInstances: { + [ProviderInstanceId.make("pi")]: { + driver: ProviderDriverKind.make("pi"), + enabled: true, + }, + [ProviderInstanceId.make("acpRegistry")]: { + driver: ProviderDriverKind.make("acpRegistry"), + enabled: true, + }, + [antigravityInstanceId]: { + driver: ProviderDriverKind.make("antigravity"), + enabled: true, + }, + [projectDisabledInstanceId]: { driver: ProviderDriverKind.make("codex") }, + }, + projectSettingsOverrides: { + [projectId]: { + providerInstances: { [projectDisabledInstanceId]: { enabled: false } }, + }, + }, + }), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.sync(() => providers), }), adapterRegistryLayer([ codexInstanceId, + projectDisabledInstanceId, ProviderInstanceId.make("claudeAgent"), ProviderInstanceId.make("pi"), ProviderInstanceId.make("acpRegistry"), @@ -1415,6 +1550,11 @@ describe("OrchestratorMcpService provider resolution", () => { assert.isFalse(disabled!.canRunCrossProviderChildTask); assert.deepEqual(disabled!.constraints, ["Provider instance is disabled."]); + const projectDisabled = byId.get(projectDisabledInstanceId); + assert.isDefined(projectDisabled); + assert.isFalse(projectDisabled!.canRunChildTask); + assert.deepEqual(projectDisabled!.constraints, ["Provider instance is disabled."]); + const disabledWithoutAdapter = byId.get(disabledWithoutAdapterInstanceId); assert.isDefined(disabledWithoutAdapter); assert.isFalse(disabledWithoutAdapter!.canRunChildTask); @@ -1512,6 +1652,7 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed( @@ -1642,6 +1783,7 @@ describe("OrchestratorMcpService provider resolution", () => { }); const layerDependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ hasReceipt: () => Effect.succeed(false), getThreadRecords: () => Effect.succeed(parentProjection([])), @@ -1718,6 +1860,7 @@ describe("OrchestratorMcpService provider resolution", () => { let delegated = false; const layerDependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ hasReceipt: () => Effect.succeed(false), getThreadRecords: (threadId) => @@ -1825,6 +1968,7 @@ describe("OrchestratorMcpService provider resolution", () => { }; const layerDependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Ref.get(accepted).pipe( @@ -1968,6 +2112,7 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, layerRegistry, layerSettings, providerRegistryLayer([ @@ -2038,6 +2183,14 @@ describe("OrchestratorMcpService provider resolution", () => { ...request, clientRequestId: "empty", }); + const emojiAtLimit = yield* service.createThreads(scope, { + threads: [{ ...request.threads[0]!, prompt: `${"a".repeat(76)}😀 and more work.` }], + clientRequestId: "emoji-title", + }); + const titled = yield* orchestrator.getThreadProjection( + emojiAtLimit.threads[0]!.threadId, + ); + assert.equal(titled.thread.title, `${"a".repeat(76)}...`); yield* settings.updateSettings({ projectSettingsOverrides: { @@ -2074,7 +2227,7 @@ describe("OrchestratorMcpService provider resolution", () => { assert.equal(untouched.messages.length, 0); assert.equal(untouched.runs.length, 0); }).pipe(Effect.provide(layerTest)); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ), ); @@ -2182,6 +2335,7 @@ describe("OrchestratorMcpService provider resolution", () => { let delegated = false; const layerDependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ hasReceipt: () => Effect.succeed(false), assertProviderInstanceEnabled: () => Effect.void, @@ -2387,6 +2541,7 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadShell: (threadId) => Effect.succeed(threadId === boundThreadId ? boundThread : null), @@ -2456,6 +2611,7 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadShell: () => Effect.succeed(null), getThreadRecords: () => Effect.succeed(idleThreadProjection(shell)), @@ -2537,6 +2693,7 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadShell: (threadId) => Ref.getAndUpdate(lookups, (count) => count + 1).pipe( @@ -2627,6 +2784,7 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({}), ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), @@ -2821,6 +2979,7 @@ describe("OrchestratorMcpService provider resolution", () => { }; const dependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed( @@ -2919,6 +3078,7 @@ describe("OrchestratorMcpService provider resolution", () => { }; const dependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ hasReceipt: () => Effect.succeed(false), assertProviderInstanceEnabled: () => Effect.void, @@ -3004,6 +3164,7 @@ describe("OrchestratorMcpService provider resolution", () => { }; const dependencies = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ hasReceipt: () => Effect.succeed(false), assertProviderInstanceEnabled: () => Effect.void, @@ -3116,6 +3277,7 @@ describe("OrchestratorMcpService thread lookup", () => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadShell: (threadId) => Effect.succeed(shells.find((shell) => shell.id === threadId) ?? null), @@ -3259,6 +3421,7 @@ describe("OrchestratorMcpService thread list", () => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ listProjectThreads: (input) => Effect.succeed( @@ -3410,6 +3573,7 @@ describe("OrchestratorMcpService thread stop", () => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, layerAdapters, ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index b4d239485899e..e58f5dc21c766 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -62,6 +62,8 @@ import { type ServerProviderModel, ThreadId, isOrchestrationV2WorkActive, + resolveProjectProviderInstanceEnabled, + SECRET_REQUEST_UNSAVED_NOTE, } from "@t3tools/contracts"; import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; @@ -72,7 +74,7 @@ import { } from "../sourceControl/threadPullRequests.ts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -92,6 +94,10 @@ import { DispatchModeLimit, type DispatchModeRefusal, } from "../orchestration-v2/DispatchModeLimit.ts"; +import type { + ProjectionRecordField, + ProjectionRecordFilter, +} from "../orchestration-v2/ProjectionStore.ts"; import { runOutcome } from "../orchestration-v2/RestartBackgroundNote.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; @@ -738,7 +744,14 @@ function threadTitle(input: { }): string { const detail = input.title?.trim() || input.prompt?.trim(); if (!detail) return `${input.parentTitle} thread ${input.index + 1}`; - return detail.length > 80 ? `${detail.slice(0, 77)}...` : detail; + if (detail.length <= 80) return detail; + let end = 77; + const previous = detail.charCodeAt(end - 1); + const next = detail.charCodeAt(end); + if (previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) { + end -= 1; + } + return `${detail.slice(0, end)}...`; } function threadSettlement( @@ -995,7 +1008,7 @@ function timelineItem(input: { } const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const threadManagement = yield* ThreadManagementService.ThreadManagementService; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; @@ -1043,21 +1056,13 @@ const make = Effect.gen(function* () { ), ); - const loadProjection = (threadId: ThreadId, includeUserMessages = true) => + const loadThreadRecords = ( + threadId: ThreadId, + fields: ReadonlyArray, + filter?: ProjectionRecordFilter, + ) => threadManagement - .getThreadRecords( - threadId, - [ - "runs", - "messages", - "providerThreads", - "providerTurns", - "runtimeRequests", - "contextTransfers", - "turnItems", - ], - { turnItemTypes: [], messageRoles: includeUserMessages ? ["user"] : [] }, - ) + .getThreadRecords(threadId, fields, filter) .pipe( Effect.mapError((error) => failure( @@ -1067,6 +1072,21 @@ const make = Effect.gen(function* () { ), ); + const loadProjection = (threadId: ThreadId, includeUserMessages = true) => + loadThreadRecords( + threadId, + [ + "runs", + "messages", + "providerThreads", + "providerTurns", + "runtimeRequests", + "contextTransfers", + "turnItems", + ], + { turnItemTypes: [], messageRoles: includeUserMessages ? ["user"] : [] }, + ); + // A dispatcher thread holds thousands of tasks, each with its full prompt; read only the ones named. const loadTasks = (threadId: ThreadId, taskIds: ReadonlyArray) => threadManagement.getThreadRecords(threadId, ["subagents"], { subagentIds: taskIds }).pipe( @@ -1078,11 +1098,9 @@ const make = Effect.gen(function* () { // Task waits read on every wake of a parent that may hold thousands of // tasks, so they read only the task they watch and what its status needs. - const loadTaskParent = (threadId: ThreadId, taskId: NodeId) => + const loadTaskParent = (threadId: ThreadId) => threadManagement - .getThreadRecords(threadId, ["runs", "subagents", "contextTransfers"], { - subagentIds: [taskId], - }) + .getThreadRecords(threadId, ["runs", "contextTransfers"]) .pipe( Effect.mapError((error) => failure( @@ -1447,9 +1465,7 @@ const make = Effect.gen(function* () { }); const requestKey = (clientRequestId: string | undefined): Effect.Effect => - clientRequestId === undefined - ? crypto.randomUUIDv4().pipe(Effect.orDie) - : Effect.succeed(clientRequestId); + clientRequestId === undefined ? random.uuidV4() : Effect.succeed(clientRequestId); const readTask = Effect.fn("OrchestratorMcpService.readTask")(function* ( scope: McpThreadInvocationScope, @@ -1459,8 +1475,7 @@ const make = Effect.gen(function* () { acknowledgementOperation = "task-status-acknowledge", ): Effect.fn.Return { yield* requireCapability(scope); - const parentProjection = yield* loadTaskParent(scope.thread.threadId, taskId); - const task = parentProjection.subagents.find( + const task = (yield* loadTasks(scope.thread.threadId, [taskId])).find( (candidate) => candidate.id === taskId && candidate.origin === "app_owned" && @@ -1475,6 +1490,7 @@ const make = Effect.gen(function* () { if (task.childThreadId === null) { return yield* readRemoteTask(scope, task, waitTimedOut, acknowledgeTerminal); } + const parentProjection = yield* loadTaskParent(scope.thread.threadId); const childControls = yield* threadManagement .getThreadRecords( task.childThreadId, @@ -1487,7 +1503,8 @@ const make = Effect.gen(function* () { "runtimeRequests", "nodes", ], - { messageRoles: ["user"], omitSettledLeafNodes: true }, + // Progress reads only active or undelivered tasks under the child. + { messageRoles: ["user"], omitSettledLeafNodes: true, omitSettledSubagents: true }, ) .pipe(Effect.mapError(threadManagementFailure)); const childRun = delegatedTaskRun(childControls, task); @@ -2115,6 +2132,26 @@ const make = Effect.gen(function* () { "Asking for a secret requires an active run owned by this MCP provider session.", ); } + // A secret the user already pasted needs no card: it goes straight to a file. + if (input.value !== undefined) { + if (input.delivery === "secretRef") { + return yield* failure( + "invalid_request", + 'A pasted `value` is saved as a file. Leave out `delivery: "secretRef"`.', + ); + } + const file = yield* secretRequests + .savePasted({ + threadId, + requestNamespace: scope.requestNamespace, + requestKey: yield* requestKey(input.clientRequestId), + value: input.value, + }) + .pipe(Effect.mapError((error) => failure("orchestration_error", error.message))); + yield* Effect.annotateCurrentSpan({ "secret_request.status": "saved_pasted" }); + // Coder: no orchestration metrics (no secretRequestsTotal counter). + return { status: "saved" as const, file }; + } const runId = run.id; const nodeId = run.rootNodeId; const key = yield* requestKey(input.clientRequestId); @@ -2215,7 +2252,7 @@ const make = Effect.gen(function* () { ); yield* Effect.annotateCurrentSpan({ "secret_request.status": status }); // Coder: no orchestration metrics (no secretRequestsTotal counter). - if (status !== "saved") return { status }; + if (status !== "saved") return { status, note: SECRET_REQUEST_UNSAVED_NOTE[status] }; if (input.delivery === "file") { const file = yield* secretRequests .saveToFile({ threadId, turnItemId }) @@ -2256,8 +2293,16 @@ const make = Effect.gen(function* () { runtimeMode: limits.runtimeMode, interactionMode: limits.interactionMode, providers: providers.map((provider) => { + // Snapshots list instances any project enables; delegate_task checks the caller's project. + const enabledForCaller = + parent === undefined || + resolveProjectProviderInstanceEnabled( + settings, + parent.thread.projectId, + provider.instanceId, + ); const constraints = providerConstraints( - provider, + enabledForCaller ? provider : { ...provider, enabled: false }, orchestrationCapableInstanceIds.has(provider.instanceId), ); const { listed, omitted } = listCapabilityModels({ @@ -2533,7 +2578,7 @@ const make = Effect.gen(function* () { return yield* remoteDelegation.cancel(scope, remoteTask, input); } const key = yield* requestKey(input.clientRequestId); - const parentProjection = yield* loadProjection(scope.thread.threadId); + const parentProjection = yield* loadThreadRecords(scope.thread.threadId, []); // Cancelling stops the child and every task under it, each a write to a // thread its user may have raised above the parent's modes since it was // delegated. All of them are checked before anything is stopped. @@ -2550,13 +2595,11 @@ const make = Effect.gen(function* () { shell.interactionMode, ); } - const { subagents } = yield* threadManagement - .getThreadRecords(threadId, ["subagents"]) + const childThreadIds = yield* threadManagement + .listAppOwnedChildThreadIds(threadId) .pipe(Effect.mapError(threadManagementFailure)); - for (const task of subagents) { - if (task.origin === "app_owned" && task.childThreadId !== null) { - yield* assertStoppable(task.childThreadId); - } + for (const childThreadId of childThreadIds) { + yield* assertStoppable(childThreadId); } }); yield* assertStoppable(childThreadId); @@ -2697,7 +2740,7 @@ const make = Effect.gen(function* () { status: cancelled ? "cancelled" : current.status, } satisfies OrchestratorMcpTaskCancelResult; } - const child = yield* loadProjection(childThreadId); + const child = yield* loadThreadRecords(childThreadId, ["runs"]); if ( current.workState !== "waiting_for_children" && ThreadManagementService.latestActiveRun(child) === undefined && @@ -3251,7 +3294,7 @@ const make = Effect.gen(function* () { export const layer: Layer.Layer< OrchestratorMcpService, never, - | Crypto.Crypto + | SecureRandom.SecureRandom | ThreadManagementService.ThreadManagementService | ThreadLaunchService.ThreadLaunchService | GitVcsDriver.GitVcsDriver diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index c8b3f4ed3abf6..7d6f894e79edc 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { @@ -161,6 +162,7 @@ it.effect("task_cancel ends a delegated child's background native subagent", () Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), NodeServices.layer, + NodeCrypto.layer, ), ), ); diff --git a/apps/server/src/mcp/TaskCancelUsageLimit.integration.test.ts b/apps/server/src/mcp/TaskCancelUsageLimit.integration.test.ts index e4176219244cf..2812259af3379 100644 --- a/apps/server/src/mcp/TaskCancelUsageLimit.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelUsageLimit.integration.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { @@ -189,6 +190,7 @@ it.effect.each([ ServerSettings.layerTest(), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), NodeServices.layer, + NodeCrypto.layer, ), ), ); diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.ts b/apps/server/src/mcp/ThreadMetadataMcpService.ts index cc2ce2b9a8635..44f0a610e585a 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.ts @@ -9,7 +9,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -110,7 +110,7 @@ function resultFromThread(input: { } const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const threadManagement = yield* ThreadManagementService.ThreadManagementService; const update = Effect.fn("ThreadMetadataMcpService.update")(function* ( @@ -145,9 +145,7 @@ const make = Effect.gen(function* () { return yield* threadNotFound(threadManagement, threadId, `Thread ${threadId} was not found.`); } const requestKey = - input.clientRequestId === undefined - ? yield* crypto.randomUUIDv4().pipe(Effect.orDie) - : input.clientRequestId; + input.clientRequestId === undefined ? yield* random.uuidV4() : input.clientRequestId; const updateCommandId = commandId({ scope, threadId, @@ -200,5 +198,5 @@ const make = Effect.gen(function* () { export const layer: Layer.Layer< ThreadMetadataMcpService, never, - Crypto.Crypto | ThreadManagementService.ThreadManagementService + SecureRandom.SecureRandom | ThreadManagementService.ThreadManagementService > = Layer.effect(ThreadMetadataMcpService, make); diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 3be8705cc0bb0..90cf3c7f8401a 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, @@ -18,6 +19,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; @@ -95,6 +97,7 @@ interface HarnessOptions { readonly currentBranch?: string | null; readonly notARepo?: boolean; readonly newWorktreesStartFromOrigin?: boolean; + readonly projectNewWorktreesStartFromOrigin?: boolean; readonly setupScript?: "started" | "no-script" | "fails" | "dies"; readonly dispatchFails?: boolean; readonly dispatchDies?: boolean; @@ -113,6 +116,7 @@ interface HarnessOptions { readonly removeWorktreeFails?: boolean; readonly deleteLocalBranchFails?: boolean; readonly createWorktreeGate?: Effect.Effect; + readonly existsFails?: boolean; } const makeHarness = (options: HarnessOptions = {}) => { @@ -232,7 +236,7 @@ const makeHarness = (options: HarnessOptions = {}) => { ), ), ); - const listRefs = vi.fn((input: { readonly query?: string | undefined }) => + const listRefs = vi.fn((input: { readonly cwd: string; readonly query?: string | undefined }) => Effect.succeed({ refs: options.existingBranchWorktreePath === undefined @@ -301,6 +305,15 @@ const makeHarness = (options: HarnessOptions = {}) => { } satisfies Partial), ServerSettings.layerTest({ newWorktreesStartFromOrigin: options.newWorktreesStartFromOrigin ?? false, + ...(options.projectNewWorktreesStartFromOrigin === undefined + ? {} + : { + projectSettingsOverrides: { + [projectId]: { + newWorktreesStartFromOrigin: options.projectNewWorktreesStartFromOrigin, + }, + }, + }), }), Layer.mock(GitWorkflowService.GitWorkflowService)({ listRefs, @@ -319,6 +332,20 @@ const makeHarness = (options: HarnessOptions = {}) => { refreshStatus, } satisfies Partial), NodeServices.layer, + NodeCrypto.layer, + options.existsFails + ? FileSystem.layerNoop({ + exists: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: path, + }), + ), + }) + : Layer.empty, ), ), ); @@ -326,6 +353,7 @@ const makeHarness = (options: HarnessOptions = {}) => { return { layer, scope, + listRefs, dispatch, sendToThread, fetchWorktreeBaseCommit, @@ -445,7 +473,7 @@ describe("t3_worktree_handoff", () => { Effect.provide( GitVcsDriver.layer.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "handoff-remote-git-" })), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), ), @@ -520,6 +548,15 @@ describe("t3_worktree_handoff", () => { }); }); + it.effect("defaults startFromOrigin to the project's override", () => { + const harness = makeHarness({ projectNewWorktreesStartFromOrigin: true }); + return Effect.gen(function* () { + const result = yield* runHandoff(harness, { branch: "feature/project-origin" }); + expect(harness.fetchWorktreeBaseCommit).toHaveBeenCalled(); + expect(result.startedFromOrigin).toBe(true); + }); + }); + it.effect("removes the created worktree when the thread update dies with a defect", () => { const harness = makeHarness({ dispatchDies: true }); return Effect.gen(function* () { @@ -677,6 +714,7 @@ describe("t3_worktree_status", () => { worktreePath: "/worktrees/project/existing", branch: "feature/existing", }, + projectNewWorktreesStartFromOrigin: true, }); return Effect.gen(function* () { const result = yield* runStatus(harness); @@ -684,8 +722,39 @@ describe("t3_worktree_status", () => { attached: true, worktreePath: "/worktrees/project/existing", branch: "feature/existing", - defaultStartFromOrigin: false, + defaultStartFromOrigin: true, }); }); }); }); + +describe("t3_worktree_list", () => { + const listRefs = (harness: ReturnType, worktreePath: string | null) => + Effect.gen(function* () { + const service = yield* WorktreeMcpService.WorktreeMcpService; + return yield* service.listRefs({ worktreePath, workspaceRoot }); + }).pipe(Effect.provide(harness.layer)); + + it.effect("lists refs from the thread's worktree, or the project root once it was removed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const worktreePath = yield* fs.makeTempDirectoryScoped({ prefix: "worktree-list-" }); + const harness = makeHarness(); + yield* listRefs(harness, worktreePath); + yield* listRefs(harness, `${worktreePath}/removed`); + expect(harness.listRefs.mock.calls.map(([input]) => input.cwd)).toEqual([ + worktreePath, + workspaceRoot, + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("fails instead of falling back when the worktree cannot be checked", () => { + const harness = makeHarness({ existsFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(listRefs(harness, "/worktrees/project/unreadable")); + expectTypedFailure(exit, { _tag: "PlatformError" }); + expect(harness.listRefs).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 267df07f2432f..0ee45bb7f8bb3 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -1,5 +1,6 @@ import { CommandId, + type GitCommandError, MessageId, type ProjectId, WorktreeMcpFailure, @@ -8,13 +9,18 @@ import { type WorktreeMcpHandoffResult, type WorktreeMcpSetupScriptStatus, type WorktreeMcpStatusResult, + type VcsListRefsInput, + type VcsListRefsResult, } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import type * as PlatformError from "effect/PlatformError"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; @@ -34,6 +40,12 @@ export class WorktreeMcpService extends Context.Service< readonly status: ( scope: McpInvocationScope, ) => Effect.Effect; + readonly listRefs: ( + input: Omit & { + readonly worktreePath: string | null; + readonly workspaceRoot: string; + }, + ) => Effect.Effect; } >()("t3/mcp/WorktreeMcpService") {} @@ -55,7 +67,8 @@ const asOperationFailed = (prefix: string) => ); const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; + const fileSystem = yield* FileSystem.FileSystem; const threadManagement = yield* ThreadManagementService.ThreadManagementService; const projects = yield* ProjectService.ProjectService; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -116,13 +129,17 @@ const make = Effect.gen(function* () { ), ); - const readDefaultStartFromOrigin = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.newWorktreesStartFromOrigin), - asOperationFailed("Unable to read server settings"), - ); + const readDefaultStartFromOrigin = (projectId: ProjectId) => + serverSettings.getSettings.pipe( + Effect.map( + (settings) => + resolveProjectSettings(settings, projectId).settings.newWorktreesStartFromOrigin, + ), + asOperationFailed("Unable to read server settings"), + ); const handoffIds = (scope: McpThreadInvocationScope) => - crypto.randomUUIDv4().pipe( + random.uuidV4().pipe( Effect.map((uuid) => { const part = (kind: string, operation: string) => [kind, "mcp", encodeURIComponent(scope.thread.providerSessionId), operation, uuid].join( @@ -205,7 +222,8 @@ const make = Effect.gen(function* () { baseRef = localStatus.refName; } - const startFromOrigin = input.startFromOrigin ?? (yield* readDefaultStartFromOrigin); + const startFromOrigin = + input.startFromOrigin ?? (yield* readDefaultStartFromOrigin(project.id)); let worktreeBaseRef = baseRef; let baseRefName = baseRef; @@ -415,7 +433,7 @@ const make = Effect.gen(function* () { const projection = yield* loadThread(scope); const project = yield* loadProject(scope, projection.thread.projectId); - const defaultStartFromOrigin = yield* readDefaultStartFromOrigin; + const defaultStartFromOrigin = yield* readDefaultStartFromOrigin(project.id); const result: WorktreeMcpStatusResult = { attached: projection.thread.worktreePath !== null, @@ -428,13 +446,25 @@ const make = Effect.gen(function* () { }, ); - return WorktreeMcpService.of({ handoff, status }); + const listRefs: WorktreeMcpService["Service"]["listRefs"] = Effect.fn( + "WorktreeMcpService.listRefs", + )(function* ({ worktreePath, workspaceRoot, ...refs }) { + // Storage cleanup removes a worktree but keeps its path on the thread. + const cwd = + worktreePath !== null && (yield* fileSystem.exists(worktreePath)) + ? worktreePath + : workspaceRoot; + return yield* gitWorkflow.listRefs({ ...refs, cwd }); + }); + + return WorktreeMcpService.of({ handoff, status, listRefs }); }); export const layer: Layer.Layer< WorktreeMcpService, never, - | Crypto.Crypto + | SecureRandom.SecureRandom + | FileSystem.FileSystem | ThreadManagementService.ThreadManagementService | ProjectService.ProjectService | ServerSettings.ServerSettingsService diff --git a/apps/server/src/mcp/bridge/FileBridge.test.ts b/apps/server/src/mcp/bridge/FileBridge.test.ts index 06b0a20094e91..d887917c88842 100644 --- a/apps/server/src/mcp/bridge/FileBridge.test.ts +++ b/apps/server/src/mcp/bridge/FileBridge.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off - Coder: drives the plain Node file protocol and CLI directly. import { execFile, spawn } from "node:child_process"; import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/apps/server/src/mcp/bridge/FileBridge.ts b/apps/server/src/mcp/bridge/FileBridge.ts index 810d90898ba55..682161f2bd6ba 100644 --- a/apps/server/src/mcp/bridge/FileBridge.ts +++ b/apps/server/src/mcp/bridge/FileBridge.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -- Coder: a private local-file transport, no listener. +// @effect-diagnostics globalDate:off globalTimers:off - Coder: the bridge polls with plain Node timers outside Effect. /** * Coder: the transport that carries T3 tool calls to agents without MCP or a socket. * @@ -19,7 +20,7 @@ import { lstat, mkdir, open, readdir, rename, rm, writeFile } from "node:fs/prom import { tmpdir } from "node:os"; import { join } from "node:path"; -import * as Data from "effect/Data"; +import * as Schema from "effect/Schema"; import { clientSource } from "./clientSource.ts"; @@ -48,9 +49,12 @@ export interface FileBridgeCatalogEntry { } /** A tool's own failure, reported to the agent as structured output. */ -export class FileBridgeToolFailure extends Data.TaggedError("FileBridgeToolFailure")<{ - readonly value: unknown; -}> {} +export class FileBridgeToolFailure extends Schema.TaggedError()( + "FileBridgeToolFailure", + { value: Schema.Unknown }, +) {} + +const isFileBridgeToolFailure = Schema.is(FileBridgeToolFailure); export function parseFileBridgeRequest(value: unknown): FileBridgeRequest { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -172,17 +176,16 @@ export async function createFileBridge( ]); response = { id: request.id, ok: true, value }; } catch (error) { - response = - error instanceof FileBridgeToolFailure - ? { id: request.id, ok: false, value: error.value } - : { - id: request.id, - ok: false, - error: - error instanceof Error && error.message.startsWith("T3 tool") - ? error.message - : "T3 tool call failed or expired.", - }; + response = isFileBridgeToolFailure(error) + ? { id: request.id, ok: false, value: error.value } + : { + id: request.id, + ok: false, + error: + error instanceof Error && error.message.startsWith("T3 tool") + ? error.message + : "T3 tool call failed or expired.", + }; } finally { clearTimeout(timeout); controllers.delete(controller); diff --git a/apps/server/src/mcp/bridge/T3ToolBridge.test.ts b/apps/server/src/mcp/bridge/T3ToolBridge.test.ts index 76be8acd522fa..7082b8b442ca9 100644 --- a/apps/server/src/mcp/bridge/T3ToolBridge.test.ts +++ b/apps/server/src/mcp/bridge/T3ToolBridge.test.ts @@ -1,7 +1,9 @@ +// @effect-diagnostics nodeBuiltinImport:off - Coder: runs the bridge CLI as an agent would and reads its files. import { execFile } from "node:child_process"; import { access, readFile } from "node:fs/promises"; import { promisify } from "node:util"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -19,7 +21,6 @@ import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as CoderEnvironment from "../../coderEnvironment.ts"; import * as ServerConfig from "../../config.ts"; @@ -31,6 +32,7 @@ import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; import * as RemoteDelegation from "../../peer/RemoteDelegation.ts"; import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../../secrets/SecretRequests.ts"; +import * as AssetSigner from "../../assets/AssetSigner.ts"; import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; import * as ProjectSetupScriptRunner from "../../project/ProjectSetupScriptRunner.ts"; import * as ServerSettings from "../../serverSettings.ts"; @@ -113,7 +115,7 @@ const harness = (options: { ), }), Layer.mock(ProjectService.ProjectService)({ - getShell: () => Effect.succeed(Option.some(project)), + getShell: () => Effect.succeedSome(project), listShells: () => Effect.succeed([project]), }), Layer.succeed( @@ -140,6 +142,7 @@ const harness = (options: { Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(AssetSigner.AssetSigner)({}), Layer.mock(ProviderRegistry.ProviderRegistry)({}), Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}), @@ -148,9 +151,9 @@ const harness = (options: { McpProviderSessions.layer, T3ToolDispatch.layer, ServerConfig.layerTest(process.cwd(), { prefix: "t3-tool-bridge-" }).pipe( - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), - NodeServices.layer, + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), ); const context = yield* Layer.build(services); const tools = Context.get(context, T3ToolDispatch.T3ToolDispatch); diff --git a/apps/server/src/mcp/bridge/T3ToolBridge.ts b/apps/server/src/mcp/bridge/T3ToolBridge.ts index 0d88cf0abe30e..5c08014a95a60 100644 --- a/apps/server/src/mcp/bridge/T3ToolBridge.ts +++ b/apps/server/src/mcp/bridge/T3ToolBridge.ts @@ -9,11 +9,13 @@ */ import type { ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Tool from "effect/ai/Tool"; import * as Toolkit from "effect/ai/Toolkit"; @@ -125,181 +127,181 @@ interface BridgeJob { * Binds upstream's handlers once the orchestrator is available. Calls that outlive the inline * budget run in the caller's scope, so they stop with the helper. */ -export const makeBindingWith = (options: { readonly inlineBudgetMs?: number } = {}) => - Effect.gen(function* () { - const toolkit = yield* BridgedToolkit.pipe(Effect.provide(BridgedHandlersLive)); - // Handlers also read services when they run, as upstream's MCP server provides them. - const services = yield* Effect.context(); - const engine = yield* Orchestrator.OrchestratorV2; - const crypto = yield* Crypto.Crypto; - const jobScope = yield* Effect.scope; - const jobs = new Map(); - // Calls still inside their inline budget, per thread; they count toward the cap before they - // become jobs, so concurrent calls cannot all pass the check. - const inlineCalls = new Map(); - let abandonedCalls = 0; +export const makeBindingWith = Effect.fn("makeBindingWith")(function* ( + options: { readonly inlineBudgetMs?: number } = {}, +) { + const toolkit = yield* BridgedToolkit.pipe(Effect.provide(BridgedHandlersLive)); + // Handlers also read services when they run, as upstream's MCP server provides them. + const services = yield* Effect.context(); + const engine = yield* Orchestrator.OrchestratorV2; + const random = yield* SecureRandom.SecureRandom; + const jobScope = yield* Effect.scope; + const jobs = new Map(); + // Calls still inside their inline budget, per thread; they count toward the cap before they + // become jobs, so concurrent calls cannot all pass the check. + const inlineCalls = new Map(); + let abandonedCalls = 0; - const run = ( - scope: McpInvocationContext.McpInvocationScope, - request: FileBridgeRequest, - ): Effect.Effect => - ( - toolkit.handle as ( - name: string, - params: unknown, - ) => Effect.Effect< - Stream.Stream< - Tool.HandlerResult, - unknown, - McpInvocationContext.McpInvocationContext - >, - unknown - > - )(request.tool, request.params).pipe( - Effect.flatMap(Stream.runLast), - Effect.flatMap((last) => - Option.match(last, { - onNone: () => Effect.fail(failure("T3ToolFailed", "The T3 tool returned no result.")), - onSome: (result) => - // A declared failure reaches the agent as its encoded result, like MCP's - // structured content. - result.isFailure - ? Effect.fail(new FileBridgeToolFailure({ value: result.encodedResult })) - : Effect.succeed(result.encodedResult), - }), - ), - Effect.mapError((cause) => - cause instanceof FileBridgeToolFailure ? cause : toolFailure(cause), - ), - Effect.provideService(McpInvocationContext.McpInvocationContext, scope), - Effect.provide(services), - ); + const run = ( + scope: McpInvocationContext.McpInvocationScope, + request: FileBridgeRequest, + ): Effect.Effect => + // @effect-diagnostics-next-line anyUnknownInErrorContext:off - name-based dispatch erases each tool's error type; mapped below. + ( + toolkit.handle as ( + name: string, + params: unknown, + ) => Effect.Effect< + Stream.Stream< + Tool.HandlerResult, + unknown, + McpInvocationContext.McpInvocationContext + >, + unknown + > + )(request.tool, request.params).pipe( + Effect.flatMap(Stream.runLast), + Effect.flatMap((last) => + Option.match(last, { + onNone: () => Effect.fail(failure("T3ToolFailed", "The T3 tool returned no result.")), + onSome: (result) => + // A declared failure reaches the agent as its encoded result, like MCP's + // structured content. + result.isFailure + ? Effect.fail(new FileBridgeToolFailure({ value: result.encodedResult })) + : Effect.succeed(result.encodedResult), + }), + ), + Effect.mapError((cause) => + Schema.is(FileBridgeToolFailure)(cause) ? cause : toolFailure(cause), + ), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provide(services), + ); - /** The bridge task a `task_status` request reads, if it names one. */ - const jobTaskId = (request: FileBridgeRequest) => { - const taskId = (request.params as Record).taskId; - return request.tool === "task_status" && - typeof taskId === "string" && - taskId.startsWith(BRIDGE_JOB_PREFIX) - ? taskId - : undefined; - }; + /** The bridge task a `task_status` request reads, if it names one. */ + const jobTaskId = (request: FileBridgeRequest) => { + const taskId = (request.params as Record).taskId; + return request.tool === "task_status" && + typeof taskId === "string" && + taskId.startsWith(BRIDGE_JOB_PREFIX) + ? taskId + : undefined; + }; - // A finished result stays until its reply is published (`published`), so a read whose reply - // never reaches the agent can be retried. - const readJob = (scope: McpInvocationContext.McpThreadInvocationScope, taskId: string) => - Effect.gen(function* () { - const job = jobs.get(taskId); - if (job === undefined || job.threadId !== scope.thread.threadId) { - return yield* failure("TaskNotFound", "No running T3 tool call has this taskId."); - } - const exit = job.fiber.pollUnsafe(); - if (exit === undefined) return { status: "running", taskId }; - return yield* exit; - }); + // A finished result stays until its reply is published (`published`), so a read whose reply + // never reaches the agent can be retried. + const readJob = Effect.fn("readJob")(function* ( + scope: McpInvocationContext.McpThreadInvocationScope, + taskId: string, + ) { + const job = jobs.get(taskId); + if (job === undefined || job.threadId !== scope.thread.threadId) { + return yield* failure("TaskNotFound", "No running T3 tool call has this taskId."); + } + const exit = job.fiber.pollUnsafe(); + if (exit === undefined) return { status: "running", taskId }; + return yield* exit; + }); - const published: NonNullable = (scope, request) => - Effect.sync(() => { - const taskId = jobTaskId(request); - const job = taskId === undefined ? undefined : jobs.get(taskId); - if ( - taskId !== undefined && - job?.threadId === scope.thread.threadId && - job.fiber.pollUnsafe() !== undefined - ) { - jobs.delete(taskId); - } - }); + const published: NonNullable = (scope, request) => + Effect.sync(() => { + const taskId = jobTaskId(request); + const job = taskId === undefined ? undefined : jobs.get(taskId); + if ( + taskId !== undefined && + job?.threadId === scope.thread.threadId && + job.fiber.pollUnsafe() !== undefined + ) { + jobs.delete(taskId); + } + }); - const dispatch: T3ToolBinding["dispatch"] = (scope, request) => - Effect.gen(function* () { - const taskIdToRead = jobTaskId(request); - if (taskIdToRead !== undefined) return yield* readJob(scope, taskIdToRead); - if (!T3_TOOL_CATALOG.some((entry) => entry.name === request.tool)) { - return yield* failure("UnknownTool", "Unknown T3 tool. Run --list to see the tools."); - } - // Plan mode's read-only rule needs the thread; without it, refuse rather than run. - const thread = yield* engine - .getThreadShell(scope.thread.threadId) - .pipe(Effect.orElseSucceed(() => null)); - if (thread === null) { - return yield* failure( - "ThreadUnavailable", - "This thread could not be read. Retry shortly.", - ); - } - if (thread.interactionMode === "plan" && !READ_ONLY_TOOLS.has(request.tool)) { - return yield* failure("PlanModeReadOnly", "Plan mode permits read-only T3 tools only."); - } + const dispatch: T3ToolBinding["dispatch"] = Effect.fn("dispatch")(function* (scope, request) { + const taskIdToRead = jobTaskId(request); + if (taskIdToRead !== undefined) return yield* readJob(scope, taskIdToRead); + if (!T3_TOOL_CATALOG.some((entry) => entry.name === request.tool)) { + return yield* failure("UnknownTool", "Unknown T3 tool. Run --list to see the tools."); + } + // Plan mode's read-only rule needs the thread; without it, refuse rather than run. + const thread = yield* engine + .getThreadShell(scope.thread.threadId) + .pipe(Effect.orElseSucceed(() => null)); + if (thread === null) { + return yield* failure("ThreadUnavailable", "This thread could not be read. Retry shortly."); + } + if (thread.interactionMode === "plan" && !READ_ONLY_TOOLS.has(request.tool)) { + return yield* failure("PlanModeReadOnly", "Plan mode permits read-only T3 tools only."); + } - const now = Date.now(); - for (const [taskId, job] of jobs) { - if (now - job.startedAt > JOB_RETENTION_MS) { - jobs.delete(taskId); - yield* Fiber.interrupt(job.fiber); - } - } - // Refuse before the tool starts, so a refused call never half-applies. The count and the - // reservation below run without yielding, so concurrent calls see each other. - const threadId = scope.thread.threadId; - let running = inlineCalls.get(threadId) ?? 0; - for (const job of jobs.values()) { - if (job.threadId === threadId && job.fiber.pollUnsafe() === undefined) { - running++; - } - } - if (running >= MAX_JOBS_PER_THREAD) { - return yield* failure( - "TooManyRunningCalls", - "Too many T3 tool calls are still running. Read their results with task_status first.", - ); - } - inlineCalls.set(threadId, (inlineCalls.get(threadId) ?? 0) + 1); - const releaseInline = Effect.sync(() => { - const remaining = (inlineCalls.get(threadId) ?? 1) - 1; - if (remaining > 0) inlineCalls.set(threadId, remaining); - else inlineCalls.delete(threadId); - }); - const outcome = yield* Effect.gen(function* () { - const fiber = yield* run(scope, request).pipe(Effect.forkIn(jobScope)); - const exit = yield* Fiber.await(fiber).pipe( - Effect.timeoutOption(options.inlineBudgetMs ?? INLINE_BUDGET_MS), - // A caller that leaves mid-call does not stop the tool, which could half-apply it; - // the call stays counted as a job until it ends or the retention sweep drops it. - Effect.onInterrupt(() => - Effect.sync(() => { - abandonedCalls += 1; - jobs.set(`${BRIDGE_JOB_PREFIX}abandoned-${abandonedCalls}`, { - threadId, - startedAt: now, - fiber, - }); - }), - ), - ); - if (Option.isSome(exit)) return { _tag: "Done" as const, exit: exit.value }; - const taskId = `${BRIDGE_JOB_PREFIX}${yield* crypto.randomUUIDv4().pipe(Effect.orDie)}`; - // Registered before the inline slot is released, so the call is counted throughout. - jobs.set(taskId, { threadId, startedAt: now, fiber }); - return { _tag: "Job" as const, taskId }; - }).pipe(Effect.ensuring(releaseInline)); - if (outcome._tag === "Done") return yield* outcome.exit; - const taskId = outcome.taskId; - return { - status: "running", - taskId, - message: "Still running. Call task_status with this taskId for the result.", - }; - }); + const now = yield* Clock.currentTimeMillis; + for (const [taskId, job] of jobs) { + if (now - job.startedAt > JOB_RETENTION_MS) { + jobs.delete(taskId); + yield* Fiber.interrupt(job.fiber); + } + } + // Refuse before the tool starts, so a refused call never half-applies. The count and the + // reservation below run without yielding, so concurrent calls see each other. + const threadId = scope.thread.threadId; + let running = inlineCalls.get(threadId) ?? 0; + for (const job of jobs.values()) { + if (job.threadId === threadId && job.fiber.pollUnsafe() === undefined) { + running++; + } + } + if (running >= MAX_JOBS_PER_THREAD) { + return yield* failure( + "TooManyRunningCalls", + "Too many T3 tool calls are still running. Read their results with task_status first.", + ); + } + inlineCalls.set(threadId, (inlineCalls.get(threadId) ?? 0) + 1); + const releaseInline = Effect.sync(() => { + const remaining = (inlineCalls.get(threadId) ?? 1) - 1; + if (remaining > 0) inlineCalls.set(threadId, remaining); + else inlineCalls.delete(threadId); + }); + const outcome = yield* Effect.gen(function* () { + const fiber = yield* run(scope, request).pipe(Effect.forkIn(jobScope)); + const exit = yield* Fiber.await(fiber).pipe( + Effect.timeoutOption(options.inlineBudgetMs ?? INLINE_BUDGET_MS), + // A caller that leaves mid-call does not stop the tool, which could half-apply it; + // the call stays counted as a job until it ends or the retention sweep drops it. + Effect.onInterrupt(() => + Effect.sync(() => { + abandonedCalls += 1; + jobs.set(`${BRIDGE_JOB_PREFIX}abandoned-${abandonedCalls}`, { + threadId, + startedAt: now, + fiber, + }); + }), + ), + ); + if (Option.isSome(exit)) return { _tag: "Done" as const, exit: exit.value }; + const taskId = `${BRIDGE_JOB_PREFIX}${yield* random.uuidV4()}`; + // Registered before the inline slot is released, so the call is counted throughout. + jobs.set(taskId, { threadId, startedAt: now, fiber }); + return { _tag: "Job" as const, taskId }; + }).pipe(Effect.ensuring(releaseInline)); + if (outcome._tag === "Done") return yield* outcome.exit; + const taskId = outcome.taskId; + return { + status: "running", + taskId, + message: "Still running. Call task_status with this taskId for the result.", + }; + }); - // A call another workspace forwards through the gateway hub runs inline as its caller; the - // hub waits for it, so it takes no task id. - const invoke: T3ToolBinding["invoke"] = (scope, request) => - T3_TOOL_CATALOG.some((entry) => entry.name === request.tool) - ? run(scope, request) - : failure("UnknownTool", "Unknown T3 tool."); + // A call another workspace forwards through the gateway hub runs inline as its caller; the + // hub waits for it, so it takes no task id. + const invoke: T3ToolBinding["invoke"] = (scope, request) => + T3_TOOL_CATALOG.some((entry) => entry.name === request.tool) + ? run(scope, request) + : failure("UnknownTool", "Unknown T3 tool."); - return { catalog: T3_TOOL_CATALOG, dispatch, published, invoke } satisfies T3ToolBinding; - }); + return { catalog: T3_TOOL_CATALOG, dispatch, published, invoke } satisfies T3ToolBinding; +}); export const makeBinding = makeBindingWith(); diff --git a/apps/server/src/mcp/bridge/T3ToolDispatch.ts b/apps/server/src/mcp/bridge/T3ToolDispatch.ts index 08c1cf3b21431..9e532a41d568f 100644 --- a/apps/server/src/mcp/bridge/T3ToolDispatch.ts +++ b/apps/server/src/mcp/bridge/T3ToolDispatch.ts @@ -58,22 +58,19 @@ export const layer = Layer.effect( Effect.gen(function* () { const bound = yield* Ref.make(undefined); return T3ToolDispatch.of({ - bind: (binding) => Ref.set(bound, binding), + bind: Effect.fn("T3ToolDispatch.bind")((binding) => Ref.set(bound, binding)), binding: Ref.get(bound), - dispatch: (scope, request) => - Ref.get(bound).pipe( - Effect.flatMap((binding) => - binding === undefined - ? Effect.fail( - t3ToolFailure("T3ToolsNotReady", "T3 tools are starting. Retry shortly."), - ) - : binding.dispatch(scope, request), - ), - ), - published: (scope, request) => - Ref.get(bound).pipe( - Effect.flatMap((binding) => binding?.published?.(scope, request) ?? Effect.void), - ), + dispatch: Effect.fn("T3ToolDispatch.dispatch")(function* (scope, request) { + const binding = yield* Ref.get(bound); + if (binding === undefined) { + return yield* t3ToolFailure("T3ToolsNotReady", "T3 tools are starting. Retry shortly."); + } + return yield* binding.dispatch(scope, request); + }), + published: Effect.fn("T3ToolDispatch.published")(function* (scope, request) { + const binding = yield* Ref.get(bound); + yield* binding?.published?.(scope, request) ?? Effect.void; + }), }); }), ); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index ca8b373ba92ca..af421060dbcd4 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -9,7 +9,7 @@ import { ThreadId, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import type { OrchestratorV2Error } from "../orchestration-v2/Orchestrator.ts"; @@ -205,6 +205,6 @@ export const readThread = Effect.fn("mcp.readThread")(function* < }); export const newCommandId = Effect.fn("mcp.newCommandId")(function* () { - const crypto = yield* Crypto.Crypto; - return CommandId.make(`mcp:${yield* crypto.randomUUIDv4().pipe(Effect.orDie)}`); + const random = yield* SecureRandom.SecureRandom; + return CommandId.make(`mcp:${yield* random.uuidV4()}`); }); diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.test.ts b/apps/server/src/mcp/toolkits/attachment/handlers.test.ts index 6db7d3a7f0c66..155e41027edf3 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.test.ts @@ -5,6 +5,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { @@ -21,6 +22,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../../../auth/ServerSecretStore.ts"; +import * as AssetSigner from "../../../assets/AssetSigner.ts"; import * as ServerConfig from "../../../config.ts"; import * as DocumentText from "../../../provider/DocumentText.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -144,9 +146,16 @@ it.effect("only the caller that prepared a pending upload can discard it", () => }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(McpToolAccessTestkit.liveThreadsLayer, ServerSecretStore.layer).pipe( + Layer.mergeAll( + McpToolAccessTestkit.liveThreadsLayer, + AssetSigner.layer.pipe(Layer.provideMerge(ServerSecretStore.layer)), + ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-attachment-" })), - Layer.provideMerge(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))), + Layer.provideMerge( + DocumentText.layer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), ), ), ), diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index f7b2a00886e32..1ba851c2beeb3 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -9,6 +9,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; +import * as AssetSigner from "../../../assets/AssetSigner.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as Upload from "../../../assets/AttachmentUpload.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; @@ -92,7 +93,8 @@ const uploadOwner = McpInvocationContext.McpInvocationContext.pipe( export const layer = McpToolAccess.toLayer( AttachmentToolkit, - Effect.sync(() => { + Effect.gen(function* () { + const assetSigner = yield* AssetSigner.AssetSigner; const uploadOwners = new Map(); return { // A pending upload belongs to its caller until a send claims it, so @@ -107,10 +109,13 @@ export const layer = McpToolAccess.toLayer( ); } const result = yield* Upload.issueAttachmentUploadUrl(input.upload).pipe( + Effect.provideService(AssetSigner.AssetSigner, assetSigner), Effect.mapError(unavailable), ); // Coder: stage the workspace file through the upload route's own validation and store. - yield* storeWorkspaceFile(result.relativeUrl, input.path); + yield* storeWorkspaceFile(result.relativeUrl, input.path).pipe( + Effect.provideService(AssetSigner.AssetSigner, assetSigner), + ); const now = yield* Clock.currentTimeMillis; for (const [id, entry] of uploadOwners) { if (now - entry.issuedAt > UPLOAD_OWNER_TTL_MS) uploadOwners.delete(id); diff --git a/apps/server/src/mcp/toolkits/attachment/tools.ts b/apps/server/src/mcp/toolkits/attachment/tools.ts index 63038a6f71ee8..98fb5d1c1a553 100644 --- a/apps/server/src/mcp/toolkits/attachment/tools.ts +++ b/apps/server/src/mcp/toolkits/attachment/tools.ts @@ -10,7 +10,7 @@ import { OrchestratorMcpFailure, TrimmedNonEmptyString, } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -31,7 +31,7 @@ const shared = { ServerConfig.ServerConfig, ServerSecretStore.ServerSecretStore, FileSystem.FileSystem, - Crypto.Crypto, + SecureRandom.SecureRandom, ], }; // Coder: no MCP HTTP origin to POST to, so the upload names a workspace file the helper stages in diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index ceca7a47e213a..ddd7ebb971f95 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -73,7 +73,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { const DelegateTaskTool = Tool.make("delegate_task", { description: - "Start one child agent with only `task`. No parent history. Separate checkout or PR per child: `isolation:'worktree'`. Keep `taskId` for `task_status` and `task_cancel`.", + "Start one child agent with only `task`. No parent history. Separate checkout or PR per child: `isolation:'worktree'`. Keep `taskId` for `task_status` and `task_cancel`. No secrets in `task`.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -185,7 +185,7 @@ const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", { const RequestSecretTool = Tool.make("request_secret", { description: - 'Ask the user to enter a secret in a private card and wait. You never see the value. Returns a one-use `secretRef` for a tool that accepts it, or with `delivery: "file"` a private file path to read inside a shell command. Asked how to provide a secret: call this now, do not explain instead.', + 'Ask for a secret in a private card and wait; open it, don\'t offer it. Returns a one-use `secretRef`, or with `delivery: "file"` a file to read only in its command, e.g. `adb pair "$(cat )"`. Asked how to give one: call now. Pasted secret for child or script: `value`. Not one from an old thread.', parameters: OrchestratorMcpRequestSecretInput, success: OrchestratorMcpRequestSecretResult, failure: OrchestratorMcpFailure, @@ -193,7 +193,9 @@ const RequestSecretTool = Tool.make("request_secret", { dependencies, }) .annotate(Tool.Title, "Request a secret from the user") - .annotate(Tool.Destructive, false); + .annotate(Tool.Destructive, false) + // Claude Code defers MCP tools behind ToolSearch; agents that never load this one ask in chat. + .annotate(Tool.Meta, { "anthropic/alwaysLoad": true }); const CreateThreadsTool = Tool.make("create_threads", { description: diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 18226edd40966..b00ceef0e7b92 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -46,7 +46,7 @@ const clientLaunchHarness = (input: { readonly launched: Array; readonly workspaceRoot?: string; readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"]; - readonly webOrigin?: string; + readonly hostedAppUrl?: string; readonly providers?: readonly ServerProvider[]; }) => { const projectId = ProjectId.make("project:client-target"); @@ -75,7 +75,6 @@ const clientLaunchHarness = (input: { }, }), }), - NodeCrypto.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { environmentId: EnvironmentId.make("environment"), requestNamespace: "client:session-1", @@ -84,7 +83,7 @@ const clientLaunchHarness = (input: { sessionId: "session-1", label: "Claude Code", access: input.runtimeModeCeiling, - webOrigin: input.webOrigin, + hostedAppUrl: input.hostedAppUrl, }, issuedAt: 0, capabilities: new Set(["orchestration" as const]), @@ -146,7 +145,7 @@ const clientLaunchHarness = (input: { Layer.provide(NodeServices.layer), ), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); return { projectId, modelSelection, projectDefaults, dependencies: layerDependencies }; }; @@ -164,17 +163,17 @@ it.effect( readonly runtimeMode?: RuntimeMode; readonly targeted?: false; readonly overrideModel?: ModelSelection; - readonly webOrigin?: string; + readonly hostedAppUrl?: string; }, ) { - const { targeted, overrideModel, webOrigin, ...input } = params; + const { targeted, overrideModel, hostedAppUrl, ...input } = params; const launched: Array = []; const { projectId, modelSelection, dependencies } = clientLaunchHarness({ runtimeModeCeiling, defaultRuntimeMode, launched, ...(overrideModel === undefined ? {} : { overrideModel }), - ...(webOrigin === undefined ? {} : { webOrigin }), + ...(hostedAppUrl === undefined ? {} : { hostedAppUrl }), }); const toolkit = yield* ProjectToolkit.pipe( Effect.provide( @@ -203,11 +202,11 @@ it.effect( const aboveCeiling = yield* launchWith("auto-accept-edits", "full-access", { title: "Fix", - webOrigin: "https://box.example.ts.net", + hostedAppUrl: "https://t3.example.com", }); expect(aboveCeiling.launched[0]?.runtimeMode).toBe("auto-accept-edits"); expect(aboveCeiling.result).toMatchObject({ - link: `https://box.example.ts.net/environment/${encodeURIComponent(aboveCeiling.launched[0]?.threadId ?? "")}`, + link: `https://t3.example.com/threads/environment/${encodeURIComponent(aboveCeiling.launched[0]?.threadId ?? "")}`, }); const requested = yield* launchWith("full-access", "approval-required", { diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 3e8040c411075..47c5962f12e8d 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -5,7 +5,7 @@ import { OrchestratorMcpFailure, ProjectId, } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; @@ -64,10 +64,10 @@ const launchCommandId = Effect.fn("mcp.launchCommandId")(function* ( namespace: string, key: string, ) { - const crypto = yield* Crypto.Crypto; - // @effect-diagnostics-next-line preferSchemaOverJson:off - a JSON pair keeps the caller apart from the key. + const crypto = yield* Digest.Digest; + // @effect-diagnostics-next-line globalJsonInEffect:off - a JSON pair keeps the caller apart from the key. const bytes = new TextEncoder().encode(JSON.stringify([namespace, key])); - const digest = yield* crypto.digest("SHA-256", bytes).pipe(Effect.mapError(unavailable)); + const digest = yield* crypto.digest(Digest.sha256, bytes).pipe(Effect.mapError(unavailable)); return CommandId.make(`mcp:launch:${Hex.encode(digest)}`); }); diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index bd7c986f4c48a..f02042e695b8d 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -26,7 +26,8 @@ import * as ServerConfig from "../../../config.ts"; import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/ai"; import * as ProjectService from "../../../project/ProjectService.ts"; @@ -45,7 +46,7 @@ const shared = { McpInvocationContext.McpInvocationContext, ThreadManagementService.ThreadManagementService, ProjectService.ProjectService, - Crypto.Crypto, + SecureRandom.SecureRandom, ], }; const ProjectListTool = Tool.make("t3_project_list", { @@ -171,6 +172,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { ServerSettings.ServerSettingsService, ProviderRegistry.ProviderRegistry, DocumentText.DocumentText, + Digest.Digest, ], }) .annotate(Tool.Destructive, true) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 8232de983bd90..67f1fd46671fe 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -15,7 +15,7 @@ import { SourceControlProviderKind, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -40,10 +40,7 @@ import { CHILD_PULL_REQUEST_REPORT_NEXT, PullRequestsToolkit } from "./tools.ts" const PROJECT_ID = ProjectId.make("project-1"); const THREAD_ID = ThreadId.make("thread-1"); -const testCrypto = Crypto.make({ - randomBytes: (size) => new Uint8Array(size).fill(7), - digest: (_algorithm, data) => Effect.succeed(data), -}); +const testCrypto = SecureRandom.make((size) => new Uint8Array(size).fill(7)); const invocation = ( capabilities: ReadonlyArray, @@ -200,7 +197,7 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* ( Effect.succeed(id === THREAD_ID && thread ? v2PullRequestThread(thread) : null), dispatch, }), - Layer.succeed(Crypto.Crypto, testCrypto), + Layer.succeed(SecureRandom.SecureRandom, testCrypto), McpToolAccessTestkit.liveThreadsLayer, ); const toolkit = yield* PullRequestsToolkit.pipe( diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 7798ec11bff05..33ce2a0b84bdb 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -8,7 +8,7 @@ import { } from "@t3tools/contracts"; import { canonicalizeGitCafePullRequestUrl } from "@t3tools/source-control-definitions/registry"; import * as Cause from "effect/Cause"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -200,10 +200,10 @@ const make = Effect.gen(function* () { const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const projects = yield* ProjectService.ProjectService; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const commandId = (tag: string, threadId: ThreadId) => - crypto.randomUUIDv4().pipe( + random.uuidV4().pipe( Effect.orDie, Effect.map((uuid) => CommandId.make(`server:${tag}:${threadId}:${uuid}`)), ); diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 018acac5c715f..c6670c4e953d3 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -20,7 +20,7 @@ import { NonNegativeInt, ProjectId, } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/ai"; @@ -57,7 +57,7 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", { dependencies: [ McpInvocationContext.McpInvocationContext, ThreadManagementService.ThreadManagementService, - Crypto.Crypto, + SecureRandom.SecureRandom, ], }) .annotate(Tool.Title, "Organize a thread") @@ -71,7 +71,7 @@ const commandTool = { dependencies: [ McpInvocationContext.McpInvocationContext, ThreadManagementService.ThreadManagementService, - Crypto.Crypto, + SecureRandom.SecureRandom, ], }; const queueEntry = Schema.Struct({ diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index c6fabe18c6fb8..2e78dd2a4f996 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -1,6 +1,5 @@ import { OrchestratorMcpFailure } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import * as GitWorkflow from "../../../git/GitWorkflowService.ts"; import * as Project from "../../../project/ProjectService.ts"; import { readThread, unavailable } from "../../threadAccess.ts"; import * as Effect from "effect/Effect"; @@ -30,9 +29,13 @@ const handlers = { code: "invalid_request", message: "The project was not found.", }); - const git = yield* GitWorkflow.GitWorkflowService; - return yield* git - .listRefs({ ...refs, cwd: thread.worktreePath ?? project.value.workspaceRoot }) + const service = yield* WorktreeMcpService.WorktreeMcpService; + return yield* service + .listRefs({ + ...refs, + worktreePath: thread.worktreePath, + workspaceRoot: project.value.workspaceRoot, + }) .pipe(Effect.mapError(unavailable)); }), ), diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index 81a39a5e30a42..18d6840d72291 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -10,7 +10,6 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import { Tool, Toolkit } from "effect/ai"; @@ -78,7 +77,7 @@ const WorktreeListTool = Tool.make("t3_worktree_list", { McpInvocationContext.McpInvocationContext, ThreadManagementService.ThreadManagementService, ProjectService.ProjectService, - GitWorkflowService.GitWorkflowService, + WorktreeMcpService.WorktreeMcpService, ], }) .annotate(Tool.Readonly, true) diff --git a/apps/server/src/mcpApps/McpAppRequests.test.ts b/apps/server/src/mcpApps/McpAppRequests.test.ts index 1fde091c06423..8e2c998011206 100644 --- a/apps/server/src/mcpApps/McpAppRequests.test.ts +++ b/apps/server/src/mcpApps/McpAppRequests.test.ts @@ -1,4 +1,5 @@ import { + ProviderDriverKind, ProviderSessionId, ProviderThreadId, ThreadId, @@ -14,7 +15,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; -import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as McpAppModelContext from "./McpAppModelContext.ts"; @@ -137,8 +138,12 @@ describe("McpAppRequests", () => { const mcpApps: ProviderAdapter.ProviderAdapterV2McpApps = { listTools: () => Effect.succeed([ - { name: "refresh", annotations: { readOnlyHint: true } }, - { name: "model_only", _meta: { ui: { visibility: ["model"] } } }, + { name: "refresh", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } }, + { + name: "model_only", + inputSchema: { type: "object" }, + _meta: { ui: { visibility: ["model"] } }, + }, ]), callTool: (input) => Effect.sync(() => { @@ -162,7 +167,11 @@ describe("McpAppRequests", () => { assert.deepEqual(yield* requests.toolInfo({ threadId, itemId, name: "refresh" }), { callable: true, readOnly: true, - tool: { name: "refresh", annotations: { readOnlyHint: true } }, + tool: { + name: "refresh", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: true }, + }, }); }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))), ); @@ -181,6 +190,31 @@ describe("McpAppRequests", () => { }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))), ); + it.effect("reports a provider whose process has stopped as a stopped session", () => + Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + assert.equal( + yield* reason(requests.readResource({ threadId, itemId, uri: "ui://weather/app.html" })), + "session-stopped", + ); + }).pipe( + Effect.provide( + makeLayer({ + item: appItem({ t3McpApp: app }), + mcpApps: { + ...mcpApps, + readResource: () => + Effect.fail( + new ProviderAdapter.ProviderAdapterSessionStoppedError({ + driver: ProviderDriverKind.make("claudeAgent"), + }), + ), + }, + }), + ), + ), + ); + it.effect("keeps each app's latest model context, without a live session", () => Effect.gen(function* () { const requests = yield* McpAppRequests.McpAppRequests; diff --git a/apps/server/src/mcpApps/McpAppRequests.ts b/apps/server/src/mcpApps/McpAppRequests.ts index 1387cc6b20c7a..85451566746fb 100644 --- a/apps/server/src/mcpApps/McpAppRequests.ts +++ b/apps/server/src/mcpApps/McpAppRequests.ts @@ -64,6 +64,12 @@ const make = Effect.gen(function* () { const fail = (threadId: ThreadId, reason: McpAppRequestError["reason"], cause?: unknown) => new McpAppRequestError({ threadId, reason, ...(cause === undefined ? {} : { cause }) }); + const adapterFailure = (threadId: ThreadId) => (cause: ProviderAdapter.ProviderAdapterV2Error) => + fail( + threadId, + cause._tag === "ProviderAdapterSessionStoppedError" ? "session-stopped" : "request-failed", + cause, + ); const resolveApp = Effect.fn("McpAppRequests.resolveApp")(function* (input: { readonly threadId: ThreadId; @@ -112,7 +118,7 @@ const make = Effect.gen(function* () { ) { const tools = yield* resolved.mcpApps .listTools({ providerThread: resolved.providerThread, server: resolved.app.server }) - .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause))); + .pipe(Effect.mapError(adapterFailure(threadId))); return tools.find((tool) => tool.name === name); }); @@ -142,7 +148,7 @@ const make = Effect.gen(function* () { tool: input.name, arguments: input.arguments, }) - .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + .pipe(Effect.mapError(adapterFailure(input.threadId))); }); const readResource = Effect.fn("McpAppRequests.readResource")(function* ( @@ -155,7 +161,7 @@ const make = Effect.gen(function* () { server: resolved.app.server, uri: input.uri, }) - .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + .pipe(Effect.mapError(adapterFailure(input.threadId))); }); const descendsFrom = Effect.fn("McpAppRequests.descendsFrom")(function* ( diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts index d043ac09a6aee..b8e0815469f2a 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts @@ -15,7 +15,7 @@ import { import type * as DateTime from "effect/DateTime"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as Effect from "effect/Effect"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -202,7 +202,7 @@ export function makeAcpReplayRuntime(input: { ) => Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - Crypto.Crypto | Scope.Scope + SecureRandom.SecureRandom | Scope.Scope > { // Transcripts exceed the kernel's 128 KiB per-environment-variable limit. const transcriptPath = `${input.statusPath}.transcript.json`; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index cbf3ae7533d02..8a3acf4caeba1 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 NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as Option from "effect/Option"; import type { AcpRegistryAvailableCommands } from "@t3tools/provider-acp-registry/testing"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -57,7 +58,9 @@ const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect( AcpRegistryAdapterV2Driver.configSchema, ); -const layerHost = TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)); +const layerHost = TestProviderHost.layer().pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), +); const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); @@ -78,6 +81,7 @@ const layerInjectedRuntimeCatalog = Layer.mock(AcpRegistrySupport.AcpRegistryCat const layerTest = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, IdAllocator.layer, McpProviderSessions.layer, layerHost, @@ -758,19 +762,19 @@ describe("AcpRegistryAdapterV2", () => { startupActive = false; }), ), - runBackgroundProbe: (_agentId, effect) => effect.pipe(Effect.map(Option.some)), + runBackgroundProbe: (_agentId, effect) => effect.pipe(Effect.asSome), clearAvailableCommands: () => Effect.void, publishAvailableCommands: (publishedInstanceId, commands) => Deferred.succeed(commandsPublished, { instanceId: publishedInstanceId, commands, }).pipe(Effect.asVoid), - getAvailableCommands: () => Effect.succeed(Option.none()), + getAvailableCommands: () => Effect.succeedNone, watchAvailableCommands: () => Effect.never, clearLiveConfiguration: () => Effect.void, publishLiveConfiguration: (_publishedInstanceId, configuration) => Deferred.succeed(configurationPublished, configuration).pipe(Effect.asVoid), - getLiveConfiguration: () => Effect.succeed(Option.none()), + getLiveConfiguration: () => Effect.succeedNone, watchLiveConfiguration: () => Effect.never, }), Layer.mock(AcpRegistrySupport.AcpRegistryCatalog)({ @@ -929,8 +933,10 @@ describe("AcpRegistryAdapterV2", () => { env: { T3_ACP_SESSION_LIFECYCLE: "1" }, }, authMethodId: "test", + // The adapter logs nothing by default (#29100), so this runtime logs its frames. protocolLogging: { ...runtimeInput.protocolLogging, + logOutgoing: true, logger: (event) => Queue.offer(protocolEvents, event).pipe(Effect.asVoid), }, }).pipe( diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts index c145448b0ee52..94f7d492ab9a0 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; import * as Effect from "effect/Effect"; @@ -27,13 +28,15 @@ import { import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; // Coder: the replay runtime replaces the local Kilo command, so the defaults serve. -const REPLAY_SETTINGS = Schema.decodeUnknownSync(AcpRegistrySettings)({}); +const REPLAY_SETTINGS = Schema.decodeSync(AcpRegistrySettings)({}); function layerAcpRegistryProviderAdapterRegistryReplay( transcript: AcpReplayTranscript, options: { readonly replayGate?: ProviderReplayGate } = {}, ) { - const layerHost = TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)); + const layerHost = TestProviderHost.layer().pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ); return ProviderAdapterRegistry.layerFromAdaptersEffect( Effect.gen(function* () { @@ -71,6 +74,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( Layer.mergeAll( layerHost, NodeServices.layer, + NodeCrypto.layer, IdAllocator.layer, Layer.mock(AcpRegistrySupport.AcpRegistryCatalog)({ resolve: () => Effect.die("ACP registry resolver must not run during replay"), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b02530aa0b2dc..ee105b6c6684d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -9,6 +9,7 @@ import type { SDKResultMessage, SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; +import type * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; import type { AskUserQuestionInput, TaskCreateInput, @@ -17,7 +18,9 @@ import type { TaskUpdateInput, TaskUpdateOutput, } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { toolOutputImages } from "@t3tools/shared/toolOutput"; import { ChatAttachmentId, type ChatAttachment, @@ -43,10 +46,11 @@ import { RunId, ThreadId, } from "@t3tools/contracts"; +import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; import { assert, describe, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -642,7 +646,7 @@ describe("ClaudeAdapterV2 context usage", () => { output_tokens: 1, cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 300 }, }, - CLAUDE_TEST_MODEL_SELECTION, + null, "2026-08-29T00:03:00.000Z", "", DateTime.makeUnsafe("2026-08-29T00:00:00.000Z"), @@ -778,7 +782,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { attachmentsDir, fileSystem, path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator, queryRunner: { allocateSessionId: Effect.succeed("native-thread-claude-accept-edits"), @@ -792,6 +796,10 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -877,7 +885,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -895,7 +908,7 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { attachmentsDir: yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-append-" }), fileSystem, path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator: yield* IdAllocator.IdAllocatorV2, queryRunner: { allocateSessionId: Effect.succeed("native-thread-claude-append-file"), @@ -909,6 +922,10 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -977,7 +994,12 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { assert.equal(opened[0]!.extraArgs?.["system-prompt"], "Replacement"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -1005,7 +1027,12 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { assert.isUndefined(opened[0]!.extraArgs?.["settings"]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -1069,7 +1096,12 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -1200,7 +1232,7 @@ describe("ClaudeAdapterV2 resumeThread", () => { attachmentsDir: configDir, fileSystem, path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator: yield* IdAllocator.IdAllocatorV2, queryRunner: { allocateSessionId: Effect.succeed("native-thread-empty"), @@ -1252,7 +1284,12 @@ describe("ClaudeAdapterV2 resumeThread", () => { }), ).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -1275,7 +1312,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { attachmentsDir, fileSystem, path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator, queryRunner: { allocateSessionId: Effect.succeed("native-thread-claude-resume"), @@ -1289,6 +1326,10 @@ describe("ClaudeAdapterV2 resume compaction", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1526,7 +1567,12 @@ describe("ClaudeAdapterV2 resume compaction", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -1626,7 +1672,7 @@ describe("ClaudeAdapterV2 attachments", () => { attachmentsDir, fileSystem, path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator, queryRunner: { allocateSessionId: Effect.succeed("native-thread-claude-attachments"), @@ -1670,6 +1716,10 @@ describe("ClaudeAdapterV2 attachments", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(messages), @@ -2141,7 +2191,12 @@ describe("ClaudeAdapterV2 attachments", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -2166,7 +2221,7 @@ describe("ClaudeAdapterV2 native fork", () => { attachmentsDir, fileSystem, path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator, queryRunner: { allocateSessionId: Effect.succeed("source-native-session"), @@ -2180,6 +2235,10 @@ describe("ClaudeAdapterV2 native fork", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -2330,7 +2389,12 @@ describe("ClaudeAdapterV2 native fork", () => { assert.equal(openedQueries[0]?.options.resumeSessionAt, "assistant-message-cursor"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -2380,17 +2444,7 @@ lines.on("line", (line) => { .map((line) => JSON.parse(line) as Record), ), ); - // The transport closes the CLI by ending its stdin, then signalling it; the exit is real, so - // poll on the wall clock rather than the test clock. - const waitUntilClosed = Effect.promise(async () => { - for (let attempt = 0; attempt < 100; attempt++) { - const text = await NodeFS.promises.readFile(logPath, "utf8").catch(() => ""); - if (text.includes('"closed":true')) return true; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - return false; - }); - return { executable, entries, waitUntilClosed }; + return { executable, entries }; }); describe("ClaudeAdapterV2 MCP credential channel", () => { @@ -2409,9 +2463,10 @@ describe("ClaudeAdapterV2 MCP credential channel", () => { ...ClaudeAdapterV2.makeClaudeQueryOptions({ modelSelection: CLAUDE_TEST_MODEL_SELECTION, nativeThreadId: "native-thread-claude-mcp-channel", + title: "Existing T3 title", resume: false, cwd: null, - environment: { PATH: process.env.PATH ?? "/usr/bin" }, + environment: { PATH: (yield* HostProcess.Environment).PATH ?? "/usr/bin" }, mcpServers: { "t3-code": t3McpServer }, }), pathToClaudeCodeExecutable: executable, @@ -2423,6 +2478,7 @@ describe("ClaudeAdapterV2 MCP credential channel", () => { ...ProviderEventLoggers.NoOpProviderEventLoggers, native, }), + Effect.provide(NodeCrypto.layer), ); // Coder: drives the fake workspace CLI rather than the SDK's spawn hook. @@ -2466,8 +2522,13 @@ describe("ClaudeAdapterV2 MCP credential channel", () => { entries.map((entry) => entry.request), { subtype: "mcp_set_servers", servers: { "t3-code": t3McpServer } }, ); + const initialize = entries + .map((entry) => entry.request as { readonly subtype?: unknown; readonly title?: unknown }) + .find((request) => request?.subtype === "initialize"); + assert.strictEqual(initialize?.title, "Existing T3 title"); const started = entries.find((entry) => entry.argv !== undefined); assert.isDefined(started); + assert.notInclude(JSON.stringify(started?.argv), "--title"); assert.notInclude(JSON.stringify(started?.argv), "--mcp-config"); assert.notInclude(JSON.stringify(started?.argv), "dummy-mcp-credential"); assert.isFalse(started?.envHasCredential); @@ -2477,23 +2538,41 @@ describe("ClaudeAdapterV2 MCP credential channel", () => { }); describe("ClaudeAdapterV2 native session identity", () => { - const openTurnWithOrdinal = (providerTurnOrdinal: number, nativeThreadHasTurns?: boolean) => + const openTurnWithOrdinal = ( + providerTurnOrdinal: number, + nativeThreadHasTurns?: boolean, + importedNativeThreadId?: string, + ) => Effect.scoped( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; + const path = yield* Path.Path; const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-v2-session-identity-", }); + if (importedNativeThreadId !== undefined) { + // An unreadable copy in another project must not hide the readable one. + yield* fileSystem.makeDirectory( + path.join(attachmentsDir, "projects", "-other", `${importedNativeThreadId}.jsonl`), + { recursive: true }, + ); + const projectDir = path.join(attachmentsDir, "projects", "-workspace"); + yield* fileSystem.makeDirectory(projectDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(projectDir, `${importedNativeThreadId}.jsonl`), + `{"type":"user","message":{"role":"user","content":"hello"},"sessionId":"${importedNativeThreadId}"}\n`, + ); + } const openedQueries: Array = []; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, + environment: { CLAUDE_CONFIG_DIR: attachmentsDir }, attachmentsDir, fileSystem, - path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + path, + random: yield* SecureRandom.SecureRandom, idAllocator, queryRunner: { allocateSessionId: Effect.succeed("native-session-identity"), @@ -2507,6 +2586,10 @@ describe("ClaudeAdapterV2 native session identity", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -2525,12 +2608,49 @@ describe("ClaudeAdapterV2 native session identity", () => { modelSelection: CLAUDE_TEST_MODEL_SELECTION, runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); const now = yield* DateTime.now; + // Mirrors the provider thread a session import records: a strong native + // ref with no conversation head and no provider turns. + const providerThread = + importedNativeThreadId === undefined + ? yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }) + : yield* runtime.resumeThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + providerThread: { + id: idAllocator.derive.providerThread({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + providerInstanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + nativeThreadId: importedNativeThreadId, + }), + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + providerInstanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + providerSessionId: null, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeId: importedNativeThreadId, + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "not_loaded", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + contextUsage: null, + nativeMetadata: null, + goal: null, + createdAt: now, + updatedAt: now, + }, + }); yield* runtime.startTurn( makeClaudeTestTurnInput({ threadId, @@ -2546,7 +2666,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -2559,6 +2684,7 @@ describe("ClaudeAdapterV2 native session identity", () => { assert.equal(openedQueries.length, 1); assert.equal(openedQueries[0]?.options.resume, "native-session-identity"); assert.equal(openedQueries[0]?.options.sessionId, undefined); + assert.equal(openedQueries[0]?.options.title, "Claude attachment test"); }), ); @@ -2567,6 +2693,16 @@ describe("ClaudeAdapterV2 native session identity", () => { const openedQueries = yield* openTurnWithOrdinal(4, false); assert.equal(openedQueries[0]?.options.sessionId, "native-session-identity"); assert.equal(openedQueries[0]?.options.resume, undefined); + assert.equal(openedQueries[0]?.options.title, "Claude attachment test"); + }), + ); + + it.effect("resumes an imported native session on its first T3 turn", () => + Effect.gen(function* () { + const openedQueries = yield* openTurnWithOrdinal(1, false, "imported-native-session"); + assert.equal(openedQueries.length, 1); + assert.equal(openedQueries[0]?.options.resume, "imported-native-session"); + assert.equal(openedQueries[0]?.options.sessionId, undefined); }), ); }); @@ -2681,6 +2817,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly cache_creation_input_tokens: number; readonly cache_read_input_tokens: number; }; + readonly modelUsage?: SDKResultMessage["modelUsage"]; }) => claudeSdkFrame({ type: "result", @@ -2698,7 +2835,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }, - modelUsage: {}, + modelUsage: input.modelUsage ?? {}, permission_denials: [], uuid: input.uuid, session_id: WAKE_NATIVE_SESSION, @@ -2782,6 +2919,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly backgroundTasks?: ( toolUseId: string, ) => Effect.Effect; + readonly mcp?: Pick< + ClaudeAdapterV2.ClaudeAgentSdkQuerySession, + "mcpServerStatus" | "readMcpResource" | "callMcpTool" + > & + Partial>; readonly environment?: NodeJS.ProcessEnv; readonly subagentLaunchToolUseId?: string; readonly nativeSessionId?: string; @@ -2849,7 +2991,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachmentsDir, fileSystem, path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator, continuationRequests: { offer: (request) => @@ -2904,6 +3046,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.andThen(() => options?.setPermissionMode?.(mode) ?? Effect.void), ), backgroundTasks: options?.backgroundTasks ?? (() => Effect.succeed(false)), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: options?.stopTask ?? (() => Effect.void), interrupt: Effect.void, close: Queue.shutdown(processMessages), @@ -2931,6 +3077,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { permissionModeChanges.push(mode); }).pipe(Effect.andThen(() => options?.setPermissionMode?.(mode) ?? Effect.void)), backgroundTasks: options?.backgroundTasks ?? (() => Effect.succeed(false)), + mcpServerStatus: options?.mcp?.mcpServerStatus ?? Effect.succeed([]), + readMcpResource: + options?.mcp?.readMcpResource ?? + (() => Effect.die("unexpected MCP resource read")), + callMcpTool: + options?.mcp?.callMcpTool ?? (() => Effect.die("unexpected MCP tool call")), + listPermissionRules: options?.mcp?.listPermissionRules ?? Effect.succeed([]), stopTask: options?.stopTask ?? (() => Effect.void), interrupt: options?.interrupt ?? Effect.void, close: options?.close?.(sdkMessages) ?? Effect.void, @@ -3193,7 +3346,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -3425,7 +3583,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -3533,7 +3696,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal((yield* open).permissionMode, "auto"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -3600,7 +3768,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -3893,9 +4066,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { } assert.equal(notices().length, 1); }).pipe( - Effect.provide(remoteSettings.layer), Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + // Coder: the counting file system (last) stands in for NodeServices' one. + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + remoteSettings.layer, + ), ), ); }); @@ -3954,7 +4133,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isUndefined(harness.getOpenedOptions()?.allowDangerouslySkipPermissions); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -4036,9 +4220,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { } assert.equal(harness.offeredMessages.length, 2); }).pipe( - Effect.provide(remoteSettings.layer), Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + // Coder: the counting file system (last) stands in for NodeServices' one. + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + remoteSettings.layer, + ), ), ); }, @@ -4140,7 +4330,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -4242,7 +4437,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -4259,7 +4459,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -4314,7 +4519,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -4343,7 +4553,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -4420,7 +4635,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -4497,11 +4717,19 @@ describe("ClaudeAdapterV2 background wake turns", () => { { type: "tool_result", tool_use_id: id, - content: "Weather result", + content: [ + { type: "text", text: "Weather result" }, + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" }, + }, + ], is_error: isError, }, ], }, + tool_use_result: + title === undefined ? { screenshot: { mimeType: "image/png" } } : undefined, }), ); yield* Queue.offer( @@ -4534,79 +4762,668 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual(item.input, { city: "Berlin" }); } assert.equal(new Set(items.map((item) => item.id)).size, 1); + assert.deepEqual(toolOutputImages(items.at(-1)?.output), [ + { mimeType: "image/png", data: "iVBORw0KGgo=" }, + ]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + const MCP_APP_CAPABILITIES = [ + "mcp_tool_ui_meta_v1", + "mcp_read_resource_v1", + "mcp_call_result_not_for_model_v1", + ]; + + const PROBE_SERVER_STATUS = { + name: "probe.server", + status: "connected", + tools: [ + { name: "show_weather", _meta: { ui: { resourceUri: "ui://probe/weather.html" } } }, + { + name: "refresh_weather", + annotations: { readOnly: true, destructive: false }, + _meta: { ui: { visibility: ["app"] } }, + }, + ], + } satisfies ClaudeSdk.McpServerStatus; + + const runClaudeMcpAppToolTurn = Effect.fnUntraced(function* ( + capabilities: ReadonlyArray, + overrides?: Partial< + Pick< + ClaudeAdapterV2.ClaudeAgentSdkQuerySession, + "mcpServerStatus" | "readMcpResource" | "listPermissionRules" + > + >, + toolName = "mcp__probe_server__show_weather", + toolUseResult: unknown = { content: '{"tempC":21}', structuredContent: { tempC: 21 } }, + ) { + const mcpRequests: Array = []; + const harness = yield* makeWakeHarnessWithOptions({ + mcp: { + mcpServerStatus: Effect.sync(() => { + mcpRequests.push("mcpServerStatus"); + return [PROBE_SERVER_STATUS]; + }), + readMcpResource: ({ server, uri }) => + Effect.sync(() => { + mcpRequests.push({ server, uri }); + return { + contents: [ + { + uri, + mimeType: "text/html;profile=mcp-app", + text: `${server} app`, + }, + ], + }; + }), + callMcpTool: (call) => + Effect.sync(() => { + mcpRequests.push(call); + return { content: [{ type: "text", text: "refreshed" }] }; + }), + ...overrides, + }, + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("mcp-app"), + text: "Show the weather", + attachments: [], + }), + ); + const id = "weather-app-call"; + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "init", + uuid: "mcp-app-init", + session_id: WAKE_NATIVE_SESSION, + capabilities, + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "assistant", + uuid: "mcp-app-assistant", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + id: "mcp-app-message", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [ + { + type: "tool_use", + id, + name: toolName, + input: { city: "Oslo" }, + }, + ], + }, + }), + ); + // Shape recorded from Claude Code 2.1.316: content arrives flattened to text. + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "user", + uuid: "mcp-app-result", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: id, content: '{"tempC":21}' }], + }, + tool_use_result: toolUseResult, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "mcp-app-terminal", result: "Shown" }), + ); + yield* Queue.take(harness.terminalReceipts); + const item = harness.events + .flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ) + .at(-1); + assert.equal(item?.status, "completed"); + return { harness, item: item!, mcpRequests }; + }); + + it.effect("renders a Claude MCP tool's ui:// resource as an app and serves its tool calls", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { harness, item, mcpRequests } = yield* runClaudeMcpAppToolTurn(MCP_APP_CAPABILITIES); + const app = mcpAppFromToolItem(item); + assert.deepInclude(app, { + server: "probe.server", + tool: "show_weather", + resourceUri: "ui://probe/weather.html", + }); + assert.deepEqual((item.output as { readonly result?: unknown }).result, { + content: [{ type: "text", text: '{"tempC":21}' }], + structuredContent: { tempC: 21 }, + }); + const html = yield* fileSystem.readFileString( + `${harness.attachmentsDir}/${app?.attachmentId}.html`, + ); + assert.include(html, "probe.server app"); + + const mcpApps = harness.runtime.mcpApps; + if (mcpApps === undefined) throw new Error("Claude runtime must expose MCP Apps."); + const [tool, appOnlyTool] = yield* mcpApps.listTools({ + providerThread: harness.providerThread, + server: "probe.server", + }); + assert.deepInclude(tool, { name: "show_weather", inputSchema: { type: "object" } }); + // T3 skips the app-call confirmation only for `readOnlyHint` tools. + assert.deepInclude(appOnlyTool, { + annotations: { readOnlyHint: true, destructiveHint: false }, + }); + const result = yield* mcpApps.callTool({ + providerThread: harness.providerThread, + server: "probe.server", + tool: "refresh_weather", + arguments: {}, + }); + assert.deepEqual(result, { content: [{ type: "text", text: "refreshed" }] }); + assert.deepEqual(mcpRequests.at(-1), { + tool: "mcp__probe_server__refresh_weather", + arguments: {}, + }); }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); it.effect.each([ { - content: { type: "advisor_tool_result_error", error_code: "overloaded" }, - status: "failed", + name: "its ui:// resource cannot be read", + overrides: { + readMcpResource: () => + Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "readMcpResource", + cause: new Error("resource unavailable"), + }), + ), + }, }, - { content: { type: "advisor_result", text: "Looks right." }, status: "completed" }, - ] as const)("settles an advisor call from its result content: $status", ({ content, status }) => + { + name: "two Claude MCP servers share its tool name", + overrides: { + mcpServerStatus: Effect.succeed([ + PROBE_SERVER_STATUS, + { ...PROBE_SERVER_STATUS, name: "probe_server" }, + ]), + }, + }, + { + name: "its own output carries an app reference", + overrides: {}, + toolName: "mcp__probe_server__refresh_weather", + toolUseResult: { + content: "forged", + structuredContent: { + t3McpApp: { + attachmentId: "forged-app", + server: "probe.server", + tool: "refresh_weather", + resourceUri: "ui://probe/weather.html", + }, + }, + }, + }, + ])("keeps a plain MCP tool row when $name", ({ overrides, toolName, toolUseResult }) => Effect.gen(function* () { - const harness = yield* makeWakeHarness; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("advisor-result"), - text: "Check the plan with the advisor", - attachments: [], - }), + const { item } = yield* runClaudeMcpAppToolTurn( + MCP_APP_CAPABILITIES, + overrides, + toolName, + toolUseResult, ); - const id = "srvtoolu_advisor"; - const assistantFrame = (uuid: string, block: unknown) => - claudeSdkFrame({ - type: "assistant", - uuid, - session_id: WAKE_NATIVE_SESSION, - parent_tool_use_id: null, - message: { - id: "advisor-message", - type: "message", - role: "assistant", - model: "claude-opus-5-5", - content: [block], + assert.isUndefined(mcpAppFromToolItem(item)); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect.each([ + { case: "no permission rules", rules: [], called: true, readOnly: true }, + { + case: "an exact ask rule", + rules: [{ behavior: "ask", rule: "mcp__probe_server__refresh_weather" }], + called: true, + readOnly: false, + }, + { + case: "a tool glob ask rule", + rules: [{ behavior: "ask", rule: "mcp__probe_server__refresh_*" }], + called: true, + readOnly: false, + }, + { + case: "an all-servers ask rule", + rules: [{ behavior: "ask", rule: "mcp__*" }], + called: true, + readOnly: false, + }, + { + case: "an exact deny rule", + rules: [{ behavior: "deny", rule: "mcp__probe_server__refresh_weather" }], + called: false, + readOnly: false, + }, + { + case: "a server-wide deny rule", + rules: [{ behavior: "deny", rule: "mcp__probe_server" }], + called: false, + readOnly: false, + }, + { + case: "a server wildcard deny rule", + rules: [{ behavior: "deny", rule: "mcp__probe_server__*" }], + called: false, + readOnly: false, + }, + { + case: "a deny rule with a server-part wildcard", + rules: [{ behavior: "deny", rule: "mcp__*__refresh_weather" }], + called: false, + readOnly: false, + }, + { + case: "a deny rule naming a prefix of the server's name", + rules: [{ behavior: "deny", rule: "mcp__probe" }], + called: false, + readOnly: false, + }, + { + case: "a deny rule for this tool on a server alias T3 can't list", + rules: [{ behavior: "deny", rule: "mcp__probe_alias__refresh_weather" }], + called: false, + readOnly: false, + }, + { + case: "a server-wide deny rule for a server whose name contains __", + rules: [{ behavior: "deny", rule: "mcp__finance__prod" }], + server: "finance__prod", + called: false, + readOnly: false, + }, + { + case: "a deny rule naming the first part of a tool name containing __", + rules: [{ behavior: "deny", rule: "mcp__gw_example_com__refresh" }], + tool: "refresh__weather", + called: false, + readOnly: false, + }, + { + case: "a deny rule whose server wildcard spans part of a tool name containing __", + rules: [{ behavior: "deny", rule: "mcp__gw*__weather" }], + tool: "refresh__weather", + called: false, + readOnly: false, + }, + { + case: "a deny rule with a parenthesized specifier", + rules: [{ behavior: "deny", rule: "mcp__probe_server__refresh_weather(city:Oslo)" }], + called: false, + readOnly: false, + }, + { + case: "a deny rule a managed policy switched off", + rules: [ + { behavior: "deny", rule: "mcp__probe_server__refresh_weather", notInEffect: true }, + { behavior: "ask", rule: "mcp__probe_server", notInEffect: true }, + ], + called: true, + readOnly: true, + }, + { + case: "a server-wide deny rule for an alias containing __", + rules: [{ behavior: "deny", rule: "mcp__finance__prod" }], + called: false, + readOnly: false, + }, + { + case: "a deny rule for this tool on an alias containing __", + rules: [{ behavior: "deny", rule: "mcp__finance__prod__refresh_weather" }], + called: false, + readOnly: false, + }, + { + case: "an ask rule naming another tool, which may be a server-wide alias rule", + rules: [{ behavior: "ask", rule: "mcp__probe_server__refresh" }], + called: true, + readOnly: false, + }, + { + case: "a deny wildcard that may match only a server alias", + rules: [{ behavior: "deny", rule: "*notion*" }], + called: false, + readOnly: false, + }, + { + case: "an ask rule for every tool", + rules: [{ behavior: "ask", rule: "*" }], + called: true, + readOnly: false, + }, + { + case: "rules that cannot cover an MCP tool", + rules: [ + { behavior: "ask", rule: "Bash" }, + { behavior: "deny", rule: "Read(./secrets/**)" }, + { behavior: "allow", rule: "mcp__probe_server__refresh_weather" }, + ], + called: true, + readOnly: true, + }, + { + case: "permission rules that cannot be read", + rules: "unreadable", + called: false, + readOnly: false, + }, + ] as const)("applies Claude's permission rules to MCP app calls with $case", (testCase) => + Effect.gen(function* () { + const server = "server" in testCase ? testCase.server : PROBE_SERVER_STATUS.name; + const tool = "tool" in testCase ? testCase.tool : "refresh_weather"; + const { harness, mcpRequests } = yield* runClaudeMcpAppToolTurn(MCP_APP_CAPABILITIES, { + mcpServerStatus: Effect.succeed([ + { + ...PROBE_SERVER_STATUS, + name: server, + tools: [{ ...PROBE_SERVER_STATUS.tools[1], name: tool }], }, - }); - yield* harness.offerAndWait( - assistantFrame("advisor-call", { type: "server_tool_use", id, name: "advisor", input: {} }), - ); - yield* harness.offerAndWait( - assistantFrame("advisor-result", { type: "advisor_tool_result", tool_use_id: id, content }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "advisor-terminal", result: "Checked" }), + ]), + listPermissionRules: + testCase.rules === "unreadable" + ? Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "listPermissionRules", + cause: new Error("Unsupported control request subtype"), + }), + ) + : Effect.succeed(testCase.rules), + }); + const mcpApps = harness.runtime.mcpApps!; + const target = { providerThread: harness.providerThread, server }; + const [listed] = yield* mcpApps.listTools(target); + assert.deepEqual( + listed?.annotations, + testCase.readOnly + ? { readOnlyHint: true, destructiveHint: false } + : { destructiveHint: false }, ); - yield* Queue.take(harness.terminalReceipts); - const items = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" - ? [event.turnItem] - : [], + const exit = yield* Effect.exit(mcpApps.callTool({ ...target, tool, arguments: {} })); + assert.equal(Exit.isSuccess(exit), testCase.called); + assert.equal( + mcpRequests.some( + (request) => typeof request === "object" && request !== null && "arguments" in request, + ), + testCase.called, ); - assert.equal(items.at(-1)?.toolName, "advisor"); - assert.equal(items.at(-1)?.status, status); }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); - describe("MCP elicitations", () => { - const testLayer = Layer.mergeAll( - IdAllocator.layer, + it.effect("refuses app requests Claude Code could route to an overlapping server", () => + Effect.gen(function* () { + // Claude Code 2.1.303 runs `mcp__probe__server__refresh_weather` on `probe`. + const { harness, item, mcpRequests } = yield* runClaudeMcpAppToolTurn( + MCP_APP_CAPABILITIES, + { + mcpServerStatus: Effect.succeed([ + { ...PROBE_SERVER_STATUS, name: "probe__server" }, + { + name: "probe", + status: "connected", + tools: [{ name: "refresh_weather", _meta: { ui: { visibility: ["app"] } } }], + }, + ]), + }, + "mcp__probe__server__show_weather", + ); + assert.isUndefined(mcpAppFromToolItem(item)); + const callExit = yield* Effect.exit( + harness.runtime.mcpApps!.callTool({ + providerThread: harness.providerThread, + server: "probe__server", + tool: "refresh_weather", + arguments: {}, + }), + ); + // A forged app reference may name either overlapping server. + const readExit = yield* Effect.exit( + harness.runtime.mcpApps!.readResource({ + providerThread: harness.providerThread, + server: "probe", + uri: "ui://probe/weather.html", + }), + ); + assert.isTrue(Exit.isFailure(callExit)); + assert.isTrue(Exit.isFailure(readExit)); + assert.deepEqual(mcpRequests, []); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("refuses an app call whose Claude tool name matches two tools on its server", () => + Effect.gen(function* () { + const { harness, mcpRequests } = yield* runClaudeMcpAppToolTurn(MCP_APP_CAPABILITIES, { + mcpServerStatus: Effect.succeed([ + { + ...PROBE_SERVER_STATUS, + tools: [ + ...PROBE_SERVER_STATUS.tools, + { name: "refresh.weather", _meta: { ui: { visibility: ["app"] } } }, + ], + }, + ]), + }); + const exit = yield* Effect.exit( + harness.runtime.mcpApps!.callTool({ + providerThread: harness.providerThread, + server: "probe.server", + tool: "refresh_weather", + arguments: {}, + }), + ); + assert.isTrue(Exit.isFailure(exit)); + assert.notDeepInclude(mcpRequests, { + tool: "mcp__probe_server__refresh_weather", + arguments: {}, + }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("keeps a plain MCP tool row when reading its ui:// resource times out", () => + Effect.gen(function* () { + const reading = yield* Deferred.make(); + const turn = yield* runClaudeMcpAppToolTurn(MCP_APP_CAPABILITIES, { + readMcpResource: () => + Deferred.succeed(reading, undefined).pipe(Effect.andThen(Effect.never)), + }).pipe(Effect.forkChild); + yield* Deferred.await(reading); + yield* TestClock.adjust("21 seconds"); + const { item } = yield* Fiber.join(turn); + assert.isUndefined(mcpAppFromToolItem(item)); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("keeps a plain MCP tool row when the Claude CLI lacks MCP Apps support", () => + Effect.gen(function* () { + const { harness, item, mcpRequests } = yield* runClaudeMcpAppToolTurn([]); + assert.isUndefined(mcpAppFromToolItem(item)); + assert.deepEqual(mcpRequests, []); + const exit = yield* Effect.exit( + harness.runtime.mcpApps!.callTool({ + providerThread: harness.providerThread, + server: "probe.server", + tool: "refresh_weather", + arguments: {}, + }), + ); + assert.isTrue(Exit.isFailure(exit)); + assert.deepEqual(mcpRequests, []); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect.each([ + { + content: { type: "advisor_tool_result_error", error_code: "overloaded" }, + status: "failed", + }, + { content: { type: "advisor_result", text: "Looks right." }, status: "completed" }, + ] as const)("settles an advisor call from its result content: $status", ({ content, status }) => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("advisor-result"), + text: "Check the plan with the advisor", + attachments: [], + }), + ); + const id = "srvtoolu_advisor"; + const assistantFrame = (uuid: string, block: unknown) => + claudeSdkFrame({ + type: "assistant", + uuid, + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + id: "advisor-message", + type: "message", + role: "assistant", + model: "claude-opus-5-5", + content: [block], + }, + }); + yield* harness.offerAndWait( + assistantFrame("advisor-call", { type: "server_tool_use", id, name: "advisor", input: {} }), + ); + yield* harness.offerAndWait( + assistantFrame("advisor-result", { type: "advisor_tool_result", tool_use_id: id, content }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "advisor-terminal", result: "Checked" }), + ); + yield* Queue.take(harness.terminalReceipts); + const items = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ); + assert.equal(items.at(-1)?.toolName, "advisor"); + assert.equal(items.at(-1)?.status, status); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + describe("MCP elicitations", () => { + const testLayer = Layer.mergeAll( + IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer, + NodeCrypto.layer, ); const approvalOnly: ElicitationRequest = { serverName: "deploys", @@ -4871,7 +5688,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -4929,7 +5751,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -5053,7 +5880,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -5126,7 +5958,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -5189,7 +6026,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -5306,7 +6148,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -5376,7 +6223,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -5431,7 +6283,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(terminal.failure.resetAt, "2026-10-12T12:20:00.000Z"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -5522,7 +6379,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -5573,7 +6435,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -5616,7 +6483,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -5729,7 +6601,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -5784,7 +6661,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(rolledBack.providerThread.nativeConversationHeadRef?.nativeId, mainUuid); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -5853,7 +6735,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isUndefined(harness.getOpenedOptions()?.resumeSessionAt); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -5916,16 +6803,27 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(harness.getOpenedOptions()?.resumeSessionAt, pinnedHead); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); it.effect("never records a subagent uuid for a turn with no main-thread assistant message", () => Effect.gen(function* () { + const forkCalls: Array = []; const harness = yield* makeWakeHarnessWithOptions({ close: (sdkMessages) => Queue.shutdown(sdkMessages), freshQueueOnReopen: true, + forkSession: (input) => + Effect.sync(() => { + forkCalls.push(input); + return { sessionId: "forked-after-compact-rollback" }; + }), }); const now = yield* DateTime.now; const subagentUuid = "00000000-0000-4000-8000-000000000644"; @@ -6006,6 +6904,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { pastSilentTurn.providerThread.nativeConversationHeadRef?.nativeId, earlierCursor, ); + // Forks omit rolled-back turns, which Claude's transcript still holds past the pinned head. + yield* harness.runtime.forkThread({ + sourceProviderThread: pastSilentTurn.providerThread, + sourceProviderTurns: turnsAcrossSilent.slice(0, 2), + providerTurnId: providerTurn.id, + targetThreadId: ThreadId.make("thread-claude-fork-after-compact-rollback"), + }); + assert.equal(forkCalls[0]?.options.upToMessageId, earlierCursor); const interruptedBeforeCompact = yield* rollbackTo( [ { @@ -6065,7 +6971,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isUndefined(harness.getOpenedOptions()?.resumeSessionAt); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -6227,7 +7138,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -6291,7 +7207,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -6370,7 +7291,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -6428,7 +7354,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -6481,7 +7412,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -6549,7 +7485,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -6645,7 +7586,73 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual(reportedContextTokens(harness.events), [38_123]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("measures a model outside the catalog against the window Claude reports", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const modelSelection = { + instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), + model: "gateway-model", + } satisfies ModelSelection; + assert.strictEqual(harness.runtime.getModelContextWindow?.(modelSelection), undefined); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-reported-context-window"), + text: "Continue.", + attachments: [], + modelSelection, + }), + ); + const usage = { ...zeroUsage, input_tokens: 9_667, output_tokens: 2 }; + const frames = [ + usageStreamFrame({ type: "message_start", message: { id: "msg_gateway", usage } }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000680", + result: "OK", + modelUsage: { + "gateway-model": { + inputTokens: 9_667, + outputTokens: 2, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0, + contextWindow: 1_048_576, + maxOutputTokens: 32_000, + }, + }, + }), + ]; + for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "completed"); + const windows = harness.events.flatMap((event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage + ? [event.providerTurn.tokenUsage.maxTokens] + : [], + ); + assert.deepEqual(windows, [null, 1_048_576]); + assert.strictEqual(harness.runtime.getModelContextWindow?.(modelSelection), 1_048_576); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -6722,7 +7729,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -6784,7 +7796,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -6856,7 +7873,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + IdAllocator.layer, + McpProviderSessions.layer, + ), ), ), ); @@ -6876,6 +7898,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerThread: harness.providerThread, now, attemptId: RunAttemptId.make("claude-native-compact-attempt"), + modelSelection: { + instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), + model: "gateway-model", + }, text: " /COMPACT ", attachments: [], }), @@ -6895,6 +7921,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000202", result: "Compacted conversation.", + modelUsage: { + "gateway-model": { + inputTokens: 1, + outputTokens: 1, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0, + contextWindow: 200_000, + maxOutputTokens: 32_000, + }, + }, }), ); yield* Queue.take(harness.terminalReceipts); @@ -6915,6 +7953,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { event.providerTurn.tokenUsage?.usedTokens === 400, ); assert.isDefined(watermark); + const windows = harness.events.flatMap((event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage + ? [event.providerTurn.tokenUsage.maxTokens] + : [], + ); + assert.deepEqual(windows, [null, 200_000]); const completed = harness.events.findLast( (event) => event.type === "provider_turn.updated", ); @@ -6928,12 +7972,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { outputTokens: 1, cachedInputTokens: 0, cacheCreationTokens: 0, + otherUsage: { inputTokens: 0, outputTokens: 0 }, }); } }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + IdAllocator.layer, + McpProviderSessions.layer, + ), ), ), ); @@ -7160,7 +8210,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -7234,7 +8289,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -7318,7 +8378,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + IdAllocator.layer, + McpProviderSessions.layer, + ), ), ), ); @@ -7383,7 +8448,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -7625,7 +8695,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -7898,7 +8973,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -7929,7 +9009,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -7984,7 +9069,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8010,7 +9100,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8052,7 +9147,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8099,7 +9199,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8129,7 +9234,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8145,7 +9255,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8222,7 +9337,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.awaitTerminal(1); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -8274,7 +9394,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -8338,7 +9463,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8428,7 +9558,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -8554,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -8690,7 +9830,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -8800,7 +9945,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.provideService(HostProcess.Platform, "linux"), Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -8862,7 +10012,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -8920,7 +10075,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -8994,7 +10154,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -9021,7 +10186,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachmentsDir, fileSystem, path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator, continuationRequests: { offer: (request) => @@ -9043,6 +10208,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: isFirstProcess @@ -9176,7 +10345,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* changes.until(() => continuationRequests.length === 1); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -9208,7 +10382,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachmentsDir, fileSystem, path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator, continuationRequests: { offer: () => Effect.void, @@ -9235,6 +10409,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(queue), @@ -9433,7 +10611,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -9639,7 +10822,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -9726,7 +10914,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -9872,7 +11065,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -9993,7 +11191,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -10083,7 +11286,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(statusOf("turn_item.updated"), "cancelled"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -10158,7 +11366,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -10198,7 +11411,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -10287,7 +11505,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Deferred.await(closed); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -10364,7 +11587,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -10418,7 +11646,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -10464,7 +11697,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -10523,7 +11761,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -10635,7 +11878,94 @@ describe("ClaudeAdapterV2 background wake turns", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + + it.effect( + "dates a buffered wake gateway limit from its arrival, not the continuation replay", + () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-gateway-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* Queue.take(harness.terminalReceipts); + + yield* Queue.offer(harness.sdkMessages, wakeNotification); + yield* harness.offerAndWait( + makeAssistantErrorFrame({ + uuid: "00000000-0000-4000-8000-000000000634", + error: "server_error", + }), + ); + assert.lengthOf(harness.continuationRequests, 1); + const arrivedAt = yield* DateTime.now; + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000635", + result: + 'API Error: 503 [cpa/claude-opus-5-5] [429]: {"type":"error","error":{"type":"rate_limit_error","message":"All credentials are cooling down"}} (reset after 2h54m21s)', + isError: true, + apiErrorStatus: 503, + terminalReason: "api_error", + origin: { kind: "task-notification" }, + }), + ); + + yield* TestClock.adjust("1 hour"); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-wake-gateway-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "failed"); + if (terminal.status !== "failed") return; + assert.equal(terminal.failure.class, "usage_limit"); + assert.equal( + terminal.failure.resetAt, + DateTime.formatIso( + DateTime.makeUnsafe( + DateTime.toEpochMillis(arrivedAt) + (2 * 3_600 + 54 * 60 + 21) * 1_000, + ), + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -10726,7 +12056,92 @@ 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, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + + it.effect("settles a continuation whose wake turn a user turn absorbed", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-absorbed-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, wakeNotification); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* harness.awaitTerminal(1); + yield* harness.offerAndWait(wakeTurnInit); + yield* harness.offerAndWait(wakeAssistant); + yield* harness.awaitContinuation(1); + + // Claude folds this prompt into the wake turn it already opened, so one result ends both. + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-absorbed-2"), + text: "Any update on the pull request?", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + yield* harness.until(() => harness.offeredMessages.length === 2); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000140", + result: "Checks passed and the pull request is up to date.", + }), + ); + yield* harness.awaitTerminal(2); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-absorbed-3"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.awaitTerminal(3); + assert.equal(harness.terminalEvents()[2]?.status, "completed"); + assert.isTrue( + harness.events.some( + (event) => + event.type === "message.updated" && event.message.text === WAKE_ASSISTANT_TEXT, + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -10806,7 +12221,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -10865,7 +12285,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -10895,7 +12320,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -11104,42 +12534,358 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.lengthOf(harness.continuationRequests, 1); - yield* harness.runtime.startTurn( + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: wakeAttempt, + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + // Claude is still thinking, so the run stays working rather than + // settling with nothing to show. + assert.lengthOf(harness.terminalEvents(), 1); + + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* harness.awaitTerminal(2); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.lengthOf(harness.continuationRequests, 1); + assert.isTrue( + harness.events.some( + (event) => + event.type === "message.updated" && + event.message.runId === RunId.make(`run-${wakeAttempt}`) && + event.message.text === WAKE_RESULT_TEXT, + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + + // A Stop closes the first process, and a turn replaces it before its stream ends. + const stopRacedByReplacementTurn = (label: string) => + Effect.gen(function* () { + const changes = yield* makeChangeSignal; + const fileSystem = yield* FileSystem.FileSystem; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: `t3-claude-v2-${label}-`, + }); + const processQueues: Array> = []; + // The first two processes exit only when the test ends their streams. + const closeRequested = [yield* Deferred.make(), yield* Deferred.make()]; + const events: Array = []; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator: yield* IdAllocator.IdAllocatorV2, + continuationRequests: { offer: () => Effect.void }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + const closeSignal = closeRequested[processQueues.length]; + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + stopTask: () => Effect.void, + mcpServerStatus: Effect.die("unexpected MCP server status"), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + interrupt: Effect.void, + close: + closeSignal === undefined + ? Queue.shutdown(sdkMessages) + : Deferred.succeed(closeSignal, undefined).pipe(Effect.asVoid), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make(`thread-claude-${label}`); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make(`provider-session-claude-${label}`), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }).pipe(Effect.andThen(changes.notify)), + ), + Effect.forkScoped, + ); + const terminals = () => events.filter((event) => event.type === "turn.terminal"); + const subagentStatus = (taskId: string) => { + const latest = events.findLast( + (event) => + event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === taskId, + ); + return latest?.type === "subagent.updated" ? latest.subagent.status : undefined; + }; + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make(`attempt-claude-${label}-a`), + text: "Audit the last commits in the background.", + attachments: [], + }), + ); + yield* Queue.offer( + processQueues[0]!, + makeSubagentTaskStartedFrame({ + taskId: "raced-agent", + toolUseId: "toolu-raced-agent", + uuid: "00000000-0000-4000-8000-000000002801", + }), + ); + yield* Queue.offer( + processQueues[0]!, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000002802", + result: "Launched the audit.", + }), + ); + yield* changes.until( + () => terminals().length === 1 && subagentStatus("raced-agent") === "running", + ); + + const stop = yield* runtime + .interruptTurn({ + providerThread, + providerTurnId: terminals()[0]!.providerTurnId, + requestRuntimeRestart: true, + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(closeRequested[0]!); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make(`attempt-claude-${label}-b`), + text: "Resume after the limit reset.", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + assert.lengthOf(processQueues, 2); + yield* Queue.offer( + processQueues[1]!, + makeSubagentTaskStartedFrame({ + taskId: "own-agent", + toolUseId: "toolu-own-agent", + uuid: "00000000-0000-4000-8000-000000002804", + }), + ); + yield* changes.until(() => subagentStatus("own-agent") === "running"); + const exitProcess = (index: number, stopping: Fiber.Fiber) => + Queue.shutdown(processQueues[index]!).pipe(Effect.andThen(Fiber.join(stopping))); + return { + runtime, + threadId, + providerThread, + processQueues, + closeRequested, + changes, + terminals, + subagentStatus, + exitFirstProcess: exitProcess(0, stop), + exitProcess, + now, + }; + }); + + // regression: #28331 a subagent whose process is gone must not stay running forever + it.effect("ends a subagent whose process a Stop closed while a new turn replaced it", () => + Effect.scoped( + Effect.gen(function* () { + const { processQueues, changes, terminals, subagentStatus, exitFirstProcess } = + yield* stopRacedByReplacementTurn("raced-stop-replacement-close"); + yield* exitFirstProcess; + // The replacement's result follows the stopped process's exit on the event stream. + yield* Queue.offer( + processQueues[1]!, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000002803", + result: "Resumed.", + }), + ); + yield* changes.until(() => terminals().length === 2); + + assert.strictEqual( + subagentStatus("raced-agent"), + "cancelled", + "the Stop ended the subagent's process, but the subagent is not cancelled", + ); + assert.strictEqual( + subagentStatus("own-agent"), + "running", + "the replacement process still runs its own subagent", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + + // regression: #28331 Stop must clear a subagent whose process is gone + it.effect("stops a subagent whose process a Stop closed while a new turn replaced it", () => + Effect.scoped( + Effect.gen(function* () { + const { runtime, providerThread, exitFirstProcess } = yield* stopRacedByReplacementTurn( + "raced-stop-replacement-stop-subagent", + ); + yield* exitFirstProcess; + const stopped = yield* runtime.stopSubagent!({ + providerThread: { ...providerThread, status: "active" }, + nativeTaskId: "raced-agent", + }).pipe( + Effect.map((outcome) => outcome?.status ?? "no outcome"), + Effect.catch((error) => + Effect.succeed(`refused: ${"detail" in error ? error.detail : error.message}`), + ), + ); + + assert.strictEqual(stopped, "cancelled", "Stop of a subagent whose process is gone"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + + // regression: #28331 a process that exits late ends only the subagents it ran + it.effect("leaves a stopping replacement's subagent to that replacement's exit", () => + Effect.scoped( + Effect.gen(function* () { + const { + runtime, + threadId, + providerThread, + processQueues, + closeRequested, + changes, + terminals, + subagentStatus, + exitFirstProcess, + exitProcess, + now, + } = yield* stopRacedByReplacementTurn("raced-stop-replacement-chain"); + const activeThread = { ...providerThread, status: "active" as const }; + const finishTurn = (index: number, turns: number, uuid: string) => + Queue.offer(processQueues[index]!, makeResultFrame({ uuid, result: "Done." })).pipe( + Effect.andThen(changes.until(() => terminals().length === turns)), + ); + const startTurn = (label: string, providerTurnOrdinal: number) => + runtime.startTurn( makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, + threadId, + providerThread: activeThread, now, - attemptId: wakeAttempt, - text: "Background task completed.", + attemptId: RunAttemptId.make(`attempt-claude-raced-stop-replacement-chain-${label}`), + text: "Carry on.", attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", + providerTurnOrdinal, }), ); - // Claude is still thinking, so the run stays working rather than - // settling with nothing to show. - assert.lengthOf(harness.terminalEvents(), 1); - yield* harness.offerAndWait(wakeAssistant); - yield* harness.offerAndWait(wakeResult); - yield* harness.awaitTerminal(2); - assert.equal(harness.terminalEvents()[1]?.status, "completed"); - assert.lengthOf(harness.continuationRequests, 1); - assert.isTrue( - harness.events.some( - (event) => - event.type === "message.updated" && - event.message.runId === RunId.make(`run-${wakeAttempt}`) && - event.message.text === WAKE_RESULT_TEXT, - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + yield* finishTurn(1, 2, "00000000-0000-4000-8000-000000002805"); + const stopSecond = yield* runtime + .interruptTurn({ + providerThread: activeThread, + providerTurnId: terminals()[1]!.providerTurnId, + requestRuntimeRestart: true, + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(closeRequested[1]!); + yield* startTurn("c", 3); + assert.lengthOf(processQueues, 3); + + yield* exitFirstProcess; + yield* finishTurn(2, 3, "00000000-0000-4000-8000-000000002806"); + assert.strictEqual( + subagentStatus("raced-agent"), + "cancelled", + "the first process exited after a Stop, but its subagent is not cancelled", + ); + assert.strictEqual( + subagentStatus("own-agent"), + "running", + "the first process ended a subagent the second process still ran", + ); + + yield* exitProcess(1, stopSecond); + yield* startTurn("d", 4); + yield* finishTurn(2, 4, "00000000-0000-4000-8000-000000002807"); + assert.strictEqual( + subagentStatus("own-agent"), + "cancelled", + "the second process exited after a Stop, but its subagent is not cancelled", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), + ), ); it.effect("streams a background subagent's work while the root turn is idle", () => @@ -11295,7 +13041,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -11479,7 +13230,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -11605,7 +13361,204 @@ 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, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + + it.effect("keeps an idle-launched nested subagent completed through a sibling's wake", () => + Effect.scoped( + Effect.gen(function* () { + const OUTER_TASK_ID = "task-16355-outer"; + const OUTER_TOOL_USE_ID = "toolu-16355-outer"; + const NESTED_TASK_ID = "task-16355-nested"; + const NESTED_TOOL_USE_ID = "toolu-16355-nested"; + const SIBLING_TASK_ID = "task-16355-sibling"; + const SIBLING_TOOL_USE_ID = "toolu-16355-sibling"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const nestedStatuses = () => + harness.events.flatMap((event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === NESTED_TASK_ID + ? [event.subagent.status] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-16355-1"), + text: "Run two audits in the background.", + attachments: [], + }), + ); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000016301", + }), + makeSubagentTaskStartedFrame({ + taskId: SIBLING_TASK_ID, + toolUseId: SIBLING_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000016302", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000016303", + result: "Both audits run in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitTerminal(1); + + // The background subagent launches a foreground nested one while the root is idle. + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_16355_nested_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: NESTED_TOOL_USE_ID, + name: "Agent", + input: { description: "Nested check", prompt: "Sleep, then reply NESTED_OK" }, + }, + ], + }, + parent_tool_use_id: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000016304", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: NESTED_TASK_ID, + tool_use_id: NESTED_TOOL_USE_ID, + description: "Nested check", + subagent_type: "general-purpose", + is_backgrounded: false, + spawn_depth: 2, + task_type: "local_agent", + prompt: "Sleep, then reply NESTED_OK", + uuid: "00000000-0000-4000-8000-000000016305", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: NESTED_TASK_ID, + description: "Sleeping", + uuid: "00000000-0000-4000-8000-000000016306", + session_id: WAKE_NATIVE_SESSION, + }), + ]) { + yield* harness.offerAndWait(frame); + } + + // A user turn runs while the nested and then the outer subagent finish. + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-16355-2"), + text: "Sleep for a while.", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + yield* harness.until(() => harness.offeredMessages.length === 2); + for (const frame of [ + makeSubagentNotificationFrame({ + taskId: NESTED_TASK_ID, + toolUseId: NESTED_TOOL_USE_ID, + summary: "NESTED_OK", + uuid: "00000000-0000-4000-8000-000000016307", + }), + makeSubagentToolResultFrame({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000016308", + toolUseId: NESTED_TOOL_USE_ID, + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000016309", + text: "OUTER_DONE", + }), + makeSubagentNotificationFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + summary: "OUTER_DONE", + uuid: "00000000-0000-4000-8000-000000016310", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000016311", + result: "Slept.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitTerminal(2); + + // The sibling's completion wakes a continuation while the root is idle. + for (const frame of [ + makeSubagentNotificationFrame({ + taskId: SIBLING_TASK_ID, + toolUseId: SIBLING_TOOL_USE_ID, + summary: "SIBLING_DONE", + uuid: "00000000-0000-4000-8000-000000016312", + }), + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000016313", + text: "Both audits finished.", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000016314", + result: "Both audits finished.", + origin: { kind: "task-notification" }, + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitContinuation(1); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-16355-3"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.awaitTerminal(3); + + assert.equal(nestedStatuses().at(-1), "completed"); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -11685,7 +13638,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -11846,7 +13804,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -11995,7 +13958,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -12162,7 +14130,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -12252,7 +14225,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -12338,7 +14316,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -12450,7 +14433,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -12564,7 +14552,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -12687,7 +14680,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -12789,7 +14787,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -12908,7 +14911,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -12994,7 +15002,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.awaitTerminal(1); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -13055,7 +15068,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(settled.subagent.result, "FORK-DONE"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -13152,7 +15170,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -13409,7 +15432,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -13435,7 +15463,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachmentsDir, fileSystem, path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator, continuationRequests: { offer: (request) => @@ -13456,6 +15484,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -13576,7 +15608,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -13604,7 +15641,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachmentsDir, fileSystem, path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator, continuationRequests: { offer: (request) => @@ -13625,6 +15662,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -13755,7 +15796,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -13781,7 +15827,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachmentsDir, fileSystem, path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator, continuationRequests: { offer: () => Effect.void }, queryRunner: { @@ -13797,6 +15843,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -13940,7 +15990,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -13971,7 +16026,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), fileSystem, path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator: yield* IdAllocator.IdAllocatorV2, continuationRequests: { offer: () => Effect.void }, queryRunner: { @@ -13987,6 +16042,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.sync(() => { @@ -14071,7 +16130,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -14098,7 +16162,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachmentsDir, fileSystem, path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator, continuationRequests: { offer: () => Effect.void, @@ -14125,6 +16189,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -14200,7 +16268,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -14480,7 +16553,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -14564,7 +16642,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -14672,7 +16755,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -14927,7 +17015,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -15017,7 +17110,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -15245,7 +17343,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -15365,7 +17468,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -15471,7 +17579,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -15562,7 +17675,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -15796,7 +17914,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -15998,7 +18121,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -16045,7 +18173,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -16098,7 +18231,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -16147,7 +18285,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -16233,7 +18376,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -16322,7 +18470,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -16365,7 +18518,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -16404,7 +18562,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -16453,7 +18616,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index d3d24f1c7af86..6fc79a5497d60 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -8,6 +8,7 @@ import { type SDKMessage, type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; @@ -18,7 +19,7 @@ import { type ProviderReplayTranscript, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -843,6 +844,11 @@ function makeReplayQueryRunner( replayEffect(() => { assertNextOutboundFrame({ type: "query.background_tasks", toolUseId }); }).pipe(Effect.as(true)), + // Recorded CLIs predate MCP Apps capabilities, so replays never reach these. + mcpServerStatus: Effect.die("unexpected MCP server status in replay"), + readMcpResource: () => Effect.die("unexpected MCP resource read in replay"), + callMcpTool: () => Effect.die("unexpected MCP tool call in replay"), + listPermissionRules: Effect.die("unexpected permission rules read in replay"), interrupt: replayEffect(() => { assertNextOutboundFrame({ type: "query.interrupt" }); }), @@ -1022,7 +1028,7 @@ function layerClaudeProviderAdapterRegistryReplay( const layerServerConfig = Layer.effect( ServerConfig.ServerConfig, makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie), - ).pipe(Layer.provide(NodeServices.layer)); + ).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); return Layer.unwrap( writeReplayTaskOutputFiles(transcript).pipe( Effect.orDie, @@ -1040,7 +1046,7 @@ function layerClaudeProviderAdapterRegistryReplay( ).pipe(Effect.map((adapter) => [adapter])), ), ), - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe( Layer.provide( @@ -1048,6 +1054,7 @@ function layerClaudeProviderAdapterRegistryReplay( layerClaudeAgentSdkReplay(transcript, options), IdAllocator.layer, NodeServices.layer, + NodeCrypto.layer, layerServerConfig, ), ), @@ -1602,10 +1609,10 @@ async function recordClaudeStreamingQuery(input: { const message = ClaudeAdapterV2.makeClaudeUserMessage({ text: prompt, uuid: await Effect.runPromise( - Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4()), + SecureRandom.SecureRandom.pipe( + Effect.flatMap((random) => random.uuidV4()), Effect.filterOrFail(ClaudeAdapterV2.isClaudePromptUuid), - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), }); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7e5d20c7fd4ef..42d12db898909 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1,4 +1,9 @@ import * as DocumentText from "../../provider/DocumentText.ts"; +import { + compactDynamicToolOutput, + mcpToolResultOutput, + toolOutputImages, +} from "@t3tools/shared/toolOutput"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; import { claudeTaskOutputRoot, tailClaudeTaskOutput } from "./ClaudeTaskOutputTail.ts"; import { @@ -25,6 +30,7 @@ import { addClaudeLedgerUsage, claudeLedgerDelta, claudeModelLedger, + claudeReportedContextWindows, normalizeClaudeTurnTokenUsage, type ClaudeLedgerUsage, type ClaudeModelLedger, @@ -38,6 +44,7 @@ import type { EffortLevel, ForkSessionOptions, ForkSessionResult, + McpServerStatus as ClaudeMcpServerStatus, Options as ClaudeQueryOptions, PermissionMode, PermissionResult, @@ -64,6 +71,13 @@ import * as NodeUtil from "node:util"; import { getSubagentMessages, query } from "../../provider/Drivers/ClaudeAgentSdk.ts"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { + claudeMcpToolName, + claudeMcpToolNamePart, + claudeMcpToolPrefix, + MCP_APP_OUTPUT_KEY, + mcpAppToolResourceUri, +} from "@t3tools/shared/mcpApp"; import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; @@ -115,11 +129,13 @@ import { import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; import * as Cause from "effect/Cause"; +import * as Predicate from "effect/Predicate"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; @@ -135,6 +151,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { snapshotMcpApp } from "../../mcpApps/McpAppSnapshot.ts"; import { planClaudeSkillDispatch, type ClaudeSkillTarget, @@ -214,12 +231,6 @@ import { export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent"); const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const; -function claudeContextWindow(modelSelection: ModelSelection): number | null { - return ( - resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, modelSelection) ?? null - ); -} - interface ClaudeCacheCreation { readonly ephemeral_1h_input_tokens: number; readonly ephemeral_5m_input_tokens: number; @@ -266,7 +277,7 @@ export function claudeProviderTurnTokenUsage( readonly cache_creation?: ClaudeCacheCreation | null; readonly output_tokens: number; }, - modelSelection: ModelSelection, + maxTokens: number | null, updatedAt: string, autoCompactWindow = "", requestedAt?: DateTime.Utc, @@ -274,7 +285,6 @@ export function claudeProviderTurnTokenUsage( const inputTokens = claudeMessageInputTokens(usage); const outputTokens = usage.output_tokens; const promptCacheTtlMs = claudePromptCacheTtlMs(usage.cache_creation); - const maxTokens = claudeContextWindow(modelSelection); // The cache lifetime starts with the request, not when its response ends. const promptCacheExpiresAt = promptCacheTtlMs === undefined || requestedAt === undefined @@ -332,6 +342,13 @@ const WORKFLOW_MEMBER_TRANSCRIPT_FLUSH_MS = 500; const BACKGROUND_MOVE_TIMEOUT_MS = 5_000; // How long a Stop waits for the CLI to exit before the user is offered Force stop. const CLAUDE_STOP_CONFIRM_TIMEOUT_MS = 10_000; +// Matches the stderr tail the SDK keeps for a CLI it spawns itself. +const CLAUDE_STDERR_TAIL_LENGTH = 2_048; +// The process group is killed on exit, so its stderr closes promptly. +const CLAUDE_STDERR_DRAIN_TIMEOUT = Duration.seconds(1); +// Claude Code's refusal of bypassPermissions as root, which it explains only on stderr. +const CLAUDE_ROOT_REFUSAL = + "--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons"; const CLAUDE_GROUP_WATCH_SCRIPT = [ "{ trap '' TERM; read -r _ <&3; kill -TERM 0; sleep 5; kill -KILL 0; } /dev/null 2>&1 &", @@ -346,6 +363,8 @@ const CLAUDE_GROUP_WATCH_SCRIPT = [ export function spawnClaudeProcessGroup( options: ClaudeSpawnOptions, onExit?: () => void, + onStderr: (text: string) => void = () => {}, + onStderrClose: () => void = () => {}, ): ClaudeSpawnedProcess { const child = NodeChildProcess.spawn( "/bin/sh", @@ -354,10 +373,12 @@ export function spawnClaudeProcessGroup( cwd: options.cwd, env: options.env, signal: options.signal, - stdio: ["pipe", "pipe", "ignore", "pipe"], + stdio: ["pipe", "pipe", "pipe", "pipe"], detached: true, }, ); + // Always drained, so a chatty CLI never blocks on a full stderr pipe. + child.stderr?.setEncoding("utf8").on("data", onStderr).once("close", onStderrClose); child.once("exit", () => { try { signalProcessGroup(child.pid ?? 0, "SIGKILL"); @@ -540,6 +561,26 @@ export interface ClaudeAgentSdkQuerySession { readonly backgroundTasks: ( toolUseId: string, ) => Effect.Effect; + readonly mcpServerStatus: Effect.Effect< + ReadonlyArray, + ClaudeAgentSdkQueryRunnerError + >; + readonly readMcpResource: (input: { + readonly server: string; + readonly uri: string; + }) => Effect.Effect< + { readonly contents: ReadonlyArray }, + ClaudeAgentSdkQueryRunnerError + >; + /** Runs an MCP tool without a model turn and returns the server's raw `CallToolResult`. */ + readonly callMcpTool: (input: { + readonly tool: string; + readonly arguments: Record; + }) => Effect.Effect; + readonly listPermissionRules: Effect.Effect< + ReadonlyArray, + ClaudeAgentSdkQueryRunnerError + >; readonly interrupt: Effect.Effect; readonly close: Effect.Effect; } @@ -558,6 +599,17 @@ export class ClaudeAgentSdkQueryRunnerError extends Schema.TaggedError()( + "ClaudeCodeRootRefusalError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Claude Code exited before it started: ${CLAUDE_ROOT_REFUSAL}`; + } +} + class ClaudeMcpRegistrationTimeoutError extends Schema.TaggedError()( "ClaudeMcpRegistrationTimeoutError", {}, @@ -669,6 +721,164 @@ function closeClaudeQuery(queryRuntime: ClaudeQuery) { }); } +// SDK 0.3.316 types `mcp_call` and `list_permission_rules` but has no Query method for them; its own readMcpResource sends control requests this way. +async function sendClaudeControlRequest( + queryRuntime: ClaudeQuery, + request: { + readonly subtype: "mcp_call" | "list_permission_rules"; + readonly [key: string]: unknown; + }, +): Promise { + const send: unknown = Reflect.get(queryRuntime, "request"); + if (typeof send !== "function") { + throw new Error(`This Claude Agent SDK cannot send ${request.subtype}.`); + } + const reply: unknown = await Reflect.apply(send, queryRuntime, [request]); + return Predicate.isObject(reply) ? reply.response : undefined; +} + +const ClaudePermissionRules = Schema.Struct({ + state: Schema.Struct({ + rules: Schema.Array( + Schema.Struct({ + behavior: Schema.Literals(["allow", "deny", "ask"]), + rule: Schema.String, + notInEffect: Schema.optional(Schema.Boolean), + }), + ), + errors: Schema.optional(Schema.Array(Schema.Unknown)), + }), +}); +export type ClaudePermissionRule = (typeof ClaudePermissionRules.Type)["state"]["rules"][number]; +const decodeClaudePermissionRules = Schema.decodeUnknownEffect(ClaudePermissionRules); + +// Without these, tools carry no `_meta.ui` and a model-shaped result would reach the app. +const CLAUDE_MCP_APP_CAPABILITIES = [ + "mcp_tool_ui_meta_v1", + "mcp_read_resource_v1", + "mcp_call_result_not_for_model_v1", +] as const; +// Captured inline, unlike Codex's shared app-server: a slow read delays only this session's stream, and only this long. +const CLAUDE_MCP_APP_CAPTURE_TIMEOUT = "20 seconds"; + +function claudeSupportsMcpApps(capabilities: ReadonlySet): boolean { + return CLAUDE_MCP_APP_CAPABILITIES.every((capability) => capabilities.has(capability)); +} + +// Claude Code routes `mcp__a__b__tool` to server `a` even when `a__b` is connected, so either prefix is ambiguous. +function claudeMcpServerPrefixOverlaps( + statuses: ReadonlyArray, + server: string, +): boolean { + const prefix = claudeMcpToolPrefix(server); + return statuses.some((status) => { + if (status.name === server) return false; + const other = claudeMcpToolPrefix(status.name); + return other.startsWith(prefix) || prefix.startsWith(other); + }); +} + +function findClaudeMcpTool( + statuses: ReadonlyArray, + toolName: string, +): + | { readonly server: string; readonly tool: NonNullable[number] } + | undefined { + const matches = statuses.flatMap((status) => { + if (status.status !== "connected") return []; + const prefix = claudeMcpToolPrefix(status.name); + if (!toolName.startsWith(prefix)) return []; + return (status.tools ?? []) + .filter((tool) => `${prefix}${claudeMcpToolNamePart(tool.name)}` === toolName) + .map((tool) => ({ server: status.name, tool })); + }); + const [match, ...others] = matches; + return match === undefined || + others.length > 0 || + claudeMcpServerPrefixOverlaps(statuses, match.server) + ? undefined + : match; +} + +// Only this adapter writes app references. Claude's normalised tool names cannot tie one in raw MCP output to its server. +function withoutForgedMcpApp(output: ClaudeNativeToolOutput): ClaudeNativeToolOutput { + const value = claudeNativeToolOutputValue(output); + return compactDynamicToolOutput(value)?.[MCP_APP_OUTPUT_KEY] === undefined + ? output + : { type: "structured_tool_use_result", value: { result: value } }; +} + +/** + * Deliberately broader than Claude's matcher: Claude also matches rules against server aliases T3 + * can't list, and an alias may contain `__`, so any `mcp__` rule or wildcard may cover any MCP tool. + * A false match only refuses a card's call, or asks first. + */ +function claudeRulesMayCoverMcpTools( + rules: ReadonlyArray, + behaviors: ReadonlyArray, +): boolean { + return rules.some((entry) => { + const name = entry.rule.split("(", 1)[0] ?? entry.rule; + return ( + behaviors.includes(entry.behavior) && + entry.notInEffect !== true && + (name.startsWith("mcp__") || name.includes("*")) + ); + }); +} + +/** + * Claude reports `annotations` as `readOnly`/`destructive`/`openWorld`; apps expect MCP's `*Hint` names. + * Card calls skip Claude's permission check, so `readOnlyHint`, which skips T3's prompt, needs readable + * rules (null when not) with no ask or deny rule that may cover the tool. + */ +function claudeMcpToolForApps( + tool: NonNullable[number], + rules: ReadonlyArray | null, +) { + const annotations = tool.annotations ?? {}; + const readOnlyHint = + annotations.readOnly !== true || + (rules !== null && !claudeRulesMayCoverMcpTools(rules, ["ask", "deny"])) + ? annotations.readOnly + : undefined; + return { + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + // The CLI reports no input schemas; apps require one in `hostContext.toolInfo.tool`. + inputSchema: { type: "object" }, + ...(tool._meta === undefined ? {} : { _meta: tool._meta }), + annotations: { + ...(readOnlyHint === undefined ? {} : { readOnlyHint }), + ...(annotations.destructive === undefined + ? {} + : { destructiveHint: annotations.destructive }), + ...(annotations.openWorld === undefined ? {} : { openWorldHint: annotations.openWorld }), + }, + }; +} + +/** A model-turn MCP `tool_use_result` flattens text `content` to a string; app calls return MCP's raw `CallToolResult`. */ +function claudeMcpCallToolResult( + value: unknown, +): ProviderAdapter.ProviderAdapterV2McpCallToolResult { + const record = Predicate.isObject(value) && !Array.isArray(value) ? value : undefined; + const content = record === undefined ? value : record.content; + return { + content: + typeof content === "string" + ? [{ type: "text", text: content }] + : Array.isArray(content) + ? content + : [], + ...(record?.structuredContent === undefined + ? {} + : { structuredContent: record.structuredContent }), + ...(record?.isError === true ? { isError: true } : {}), + ...(record?._meta === undefined ? {} : { _meta: record._meta }), + }; +} + // Iterate Query itself: the raw generator's return() queues behind a pending CLI read and deadlocks interruption. export function claudeQueryMessages(queryRuntime: ClaudeQuery): AsyncIterable { return { [Symbol.asyncIterator]: () => queryRuntime }; @@ -875,14 +1085,14 @@ export const layerQueryRunner: Layer.Layer< ClaudeAgentSdkQueryRunner, never, | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | SecureRandom.SecureRandom | Path.Path | PortScanner.PortDiscovery | ProviderEventLoggers.ProviderEventLoggers > = Layer.effect( ClaudeAgentSdkQueryRunner, Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; const agentScope = yield* AgentScope; const platform = yield* HostProcess.Platform; @@ -891,9 +1101,7 @@ export const layerQueryRunner: Layer.Layer< const portDiscovery = yield* PortScanner.PortDiscovery; return ClaudeAgentSdkQueryRunner.of({ - allocateSessionId: crypto - .randomUUIDv4() - .pipe(Effect.mapError((cause) => queryRunnerError(cause, "allocateSessionId"))), + allocateSessionId: random.uuidV4(), open: Effect.fn("ClaudeAgentSdkQueryRunner.open")( function* (input: ClaudeAgentSdkQueryOpenInput) { const protocolLogger = makeClaudeAgentSdkProtocolLogger({ @@ -945,10 +1153,25 @@ export const layerQueryRunner: Layer.Layer< const unit = launch?.unit; const stopScope = unit === undefined ? undefined : () => void runFork(agentScope.stop(unit)); + // Only a CLI that fails before its first message explains itself on + // stderr. The known root refusal becomes a curated failure; any other + // stderr can hold private text, so it stays out of turn failures. + let stderrTail = ""; + let stderrClosed: Promise = Promise.resolve(); + let answered = false; + const appendStderr = (text: string) => { + if (answered) return; + stderrTail = (stderrTail + text).slice(-CLAUDE_STDERR_TAIL_LENGTH); + }; const spawnProcess = options.spawnClaudeCodeProcess ?? ((spawnOptions: ClaudeSpawnOptions) => { - if (platform !== "win32") return spawnClaudeProcessGroup(spawnOptions, stopScope); + if (platform !== "win32") { + // Node can report "exit" before it has read the stderr the CLI wrote. + let stderrDone = () => {}; + stderrClosed = new Promise((resolve) => (stderrDone = resolve)); + return spawnClaudeProcessGroup(spawnOptions, stopScope, appendStderr, stderrDone); + } const child = NodeChildProcess.spawn(spawnOptions.command, spawnOptions.args, { cwd: spawnOptions.cwd, env: spawnOptions.env, @@ -996,9 +1219,10 @@ export const layerQueryRunner: Layer.Layer< Effect.gen(function* () { if (mcpServers !== undefined) { // Errors and mcp_status can echo headers; log only server names. - const failed = yield* Effect.tryPromise(() => - queryRuntime.setMcpServers(mcpServers), - ).pipe( + const failed = yield* Effect.tryPromise({ + try: () => queryRuntime.setMcpServers(mcpServers), + catch: (cause) => queryRunnerError(cause, "setMcpServers"), + }).pipe( Effect.map((result) => Object.keys(result.errors)), Effect.orElseSucceed(() => Object.keys(mcpServers)), Effect.timeoutOrElse({ @@ -1068,9 +1292,31 @@ export const layerQueryRunner: Layer.Layer< ); return { - messages: Stream.fromAsyncIterable(claudeQueryMessages(queryRuntime), (cause) => - queryRunnerError(cause, "fromAsyncIterable"), + messages: Stream.fromAsyncIterable( + claudeQueryMessages(queryRuntime), + (cause) => cause, ).pipe( + Stream.catch((cause) => + Stream.fromEffect( + Effect.gen(function* () { + if (!answered) { + yield* Effect.promise(() => stderrClosed).pipe( + Effect.timeoutOption(CLAUDE_STDERR_DRAIN_TIMEOUT), + ); + } + const refusedRoot = !answered && stderrTail.includes(CLAUDE_ROOT_REFUSAL); + return yield* queryRunnerError( + refusedRoot ? new ClaudeCodeRootRefusalError({ cause }) : cause, + "fromAsyncIterable", + ); + }), + ), + ), + Stream.tap(() => + Effect.sync(() => { + answered = true; + }), + ), Stream.tap((message) => logProtocolEvent({ direction: "incoming", @@ -1171,6 +1417,40 @@ export const layerQueryRunner: Layer.Layer< }), ), ), + mcpServerStatus: Effect.tryPromise({ + try: () => queryRuntime.mcpServerStatus(), + catch: (cause) => queryRunnerError(cause, "mcpServerStatus"), + }), + readMcpResource: ({ server, uri }) => + Effect.tryPromise({ + try: () => queryRuntime.readMcpResource(server, uri), + catch: (cause) => queryRunnerError(cause, "readMcpResource"), + }), + callMcpTool: (call) => + Effect.tryPromise({ + try: () => + sendClaudeControlRequest(queryRuntime, { + subtype: "mcp_call", + tool: call.tool, + arguments: call.arguments, + result_not_for_model: true, + }), + catch: (cause) => queryRunnerError(cause, "callMcpTool"), + }), + listPermissionRules: Effect.tryPromise({ + try: () => + sendClaudeControlRequest(queryRuntime, { subtype: "list_permission_rules" }), + catch: (cause) => queryRunnerError(cause, "listPermissionRules"), + }).pipe( + Effect.flatMap(decodeClaudePermissionRules), + // A settings file Claude couldn't parse was skipped, so its rules are missing from the list. + Effect.filterOrFail( + ({ state }) => (state.errors ?? []).length === 0, + ({ state }) => new Error(`Claude skipped ${state.errors?.length} settings file(s)`), + ), + Effect.map(({ state }) => state.rules), + Effect.mapError((cause) => queryRunnerError(cause, "listPermissionRules")), + ), interrupt: Effect.tryPromise({ try: () => queryRuntime.interrupt(), catch: (cause) => queryRunnerError(cause, "interrupt"), @@ -1232,9 +1512,7 @@ export const layerQueryRunner: Layer.Layer< // Coder: there is no SDK `forkSession`. Allocate the fork's session id here; the // CLI copies the source transcript when the fork's first query opens. const result: ForkSessionResult = { - sessionId: yield* crypto - .randomUUIDv4() - .pipe(Effect.mapError((cause) => queryRunnerError(cause, "forkSession"))), + sessionId: yield* random.uuidV4(), }; yield* logProtocolEvent({ direction: "incoming", @@ -1374,6 +1652,7 @@ const CLAUDE_BRIDGE_BACKGROUND_WAIT_INSTRUCTIONS = CLAUDE_BACKGROUND_WAIT_INSTRU export function makeClaudeQueryOptions(input: { readonly modelSelection: ModelSelection; readonly nativeThreadId: string; + readonly title?: string; readonly resume: boolean; readonly resumeSessionAt?: string; /** Coder: open the session as a native CLI fork of this source session. */ @@ -1466,6 +1745,8 @@ export function makeClaudeQueryOptions(input: { } as ClaudeSdkSettings); const options: ClaudeAgentSdkQueryOptions = { model: compiledSelection.apiModelId, + // T3 names threads; a supplied title skips Claude's redundant naming request. + ...(input.title === undefined ? {} : { title: input.title }), tools: claudeAgentSdkQueryToolsForSdk(selectedTools), permissionMode: (launchArgPermissionMode as PermissionMode | null | undefined) ?? @@ -1923,7 +2204,8 @@ const resolveClaudeForkUpToMessageId = Effect.fn("ClaudeAdapterV2.resolveForkUpT .slice(boundaryIndex + 1) .filter(isTerminalProviderTurn); if (terminalTurnsAfterBoundary.length === 0) { - return undefined; + // Rollback pins the head but leaves the rolled-back turns in Claude's transcript. + return yield* getNativeConversationHeadId(input.sourceProviderThread); } return yield* new ProviderAdapter.ProviderAdapterForkThreadError({ @@ -2919,7 +3201,14 @@ function claudeNativeToolOutputValue(output: ClaudeNativeToolOutput): unknown | case "none": return undefined; case "content_block": + return output.value; case "structured_tool_use_result": + if (toolOutputImages(output.value).length === 0) { + return mcpToolResultOutput({ + content: output.fallbackValue, + structuredContent: output.value, + }); + } return output.value; default: return assertNever(output); @@ -3683,7 +3972,7 @@ function gatewayCooldownResetAt( function providerFailureFromResult( message: SDKResultMessage, - completedAt: DateTime.Utc, + receivedAt: DateTime.Utc, failureHint?: string, usageLimited = false, ): OrchestrationV2ProviderFailure | null { @@ -3697,7 +3986,7 @@ function providerFailureFromResult( /^API Error: 503 \[[^\]\r\n]+\] \[429\]: \{"type":"error","error":\{"type":"rate_limit_error",[^\r\n]*\} \(reset after (?:(\d+)h ?)?(?:(\d+)m ?)?(?:(\d+)s)?\)/.exec( message.result, ), - completedAt, + receivedAt, ) : undefined; if (gatewayResetAt !== undefined) { @@ -3741,7 +4030,7 @@ function providerFailureFromResult( apiErrorStatus === 429 && message.result.startsWith("API Error:") ? gatewayCooldownResetAt( /\(reset after (?:(\d+)h ?)?(?:(\d+)m ?)?(?:(\d+)s)?\)/.exec(message.result), - completedAt, + receivedAt, ) : undefined; return makeProviderFailure({ @@ -3945,6 +4234,7 @@ interface ActiveClaudeTurnContext { requestedAt?: DateTime.Utc; usage: ClaudeMessageUsage | null; } | null; + compactionTokens: number | undefined; readonly toolCalls: Map; // A successful status can precede the boundary that supplies its token counts. compaction: Extract | null; @@ -4013,9 +4303,13 @@ interface ClaudeLiveQueryContext { // The CLI may fall back from the requested mode on its first init frame. openedPermissionMode: PermissionMode; receivedInit: boolean; + // From system/init; feature-detects control requests such as MCP Apps. + capabilities: ReadonlySet; permissionMode: PermissionMode; stopping: boolean; readonly subagentsFromEarlierProcesses: ReadonlySet; + // Subagents this process still ran when a replacement took over; only its exit can end them. + handedOffSubagents: ReadonlySet; // The SDK's per-process model ledger at the last result; a new process starts at zero. modelLedger: ClaudeModelLedger | undefined; // Per process, not per session: a relative temp dir follows the cwd this CLI @@ -4418,7 +4712,7 @@ export interface ClaudeAdapterV2Options { readonly attachmentsDir: string; readonly fileSystem: FileSystem.FileSystem; readonly path: Path.Path; - readonly crypto: Crypto.Crypto; + readonly random: SecureRandom.SecureRandom["Service"]; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly queryRunner: ClaudeAgentSdkQueryRunnerShape; readonly scopedLimitNames?: Ref.Ref; @@ -4436,11 +4730,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const mcpSessions = yield* McpProviderSessions.McpProviderSessions; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverSettings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService); - const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions; + const { attachmentsDir, fileSystem, path, random, idAllocator, queryRunner } = adapterOptions; const subagentModelCatalog = scopeClaudeModelCatalog( BUNDLED_CLAUDE_MODEL_CATALOG, adapterOptions.settings.customModels, ); + // Results report the window Claude Code compacts against; outside the catalog it is the only source. + const reportedContextWindows = new Map(); + const claudeContextWindow = (modelSelection: ModelSelection): number | null => + resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, modelSelection) ?? + reportedContextWindows.get(compileClaudeModelSelection(modelSelection).apiModelId) ?? + null; const continuationRequests = adapterOptions.continuationRequests ?? { offer: () => Effect.void, }; @@ -4677,6 +4977,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const queryContext = yield* Ref.make(null); // Wake buffers replay result frames, so ledger growth is measured once on arrival. const resultLedgerUsage = new WeakMap(); + const resultsReportingNewContextWindow = new WeakSet(); // A worktree handoff changes the project settings even within one session. let bypassCache: { cwd: string | null; availability: ClaudeBypassAvailability } | null = null; @@ -4767,6 +5068,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const pendingSubagentFramesByToolUseId = yield* Ref.make( new Map>(), ); + // A gateway's relative reset counts from the result's arrival, not its replay. + const bufferedResultArrivals = new WeakMap(); const wakeBuffers = yield* Ref.make( new Map< string, @@ -4914,6 +5217,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } }); const requestedContinuations = yield* Ref.make(new Set()); + const absorbedWakes = yield* Ref.make(new Set()); const heldProposedPlansByToolUseId = new Map(); const runtimeContext = yield* Effect.context(); const runPromise = Effect.runPromiseWith(runtimeContext); @@ -4960,21 +5264,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // Usage without input is a placeholder, not a context measurement. const emitContextUsage = Effect.fnUntraced(function* ( context: ActiveClaudeTurnContext, - usage: ClaudeMessageUsage, + usage: ClaudeMessageUsage | number, requestStarted = false, ) { const now = yield* DateTime.now; // Only message_start marks the request start; an assistant snapshot // arrives after generation, so it leaves the cache expiry unknown. if (requestStarted && context.streamUsage !== null) context.streamUsage.requestedAt = now; - const tokenUsage = claudeProviderTurnTokenUsage( - usage, - context.input.modelSelection, - DateTime.formatIso(now), - adapterOptions.settings.autoCompactWindow, - context.streamUsage?.requestedAt, - ); - if (tokenUsage.inputTokens <= 0) return; + const maxTokens = claudeContextWindow(context.input.modelSelection); + const tokenUsage = + typeof usage === "number" + ? { + usedTokens: usage, + maxTokens, + ...claudeAutoCompactThreshold( + adapterOptions.settings.autoCompactWindow, + maxTokens, + ), + updatedAt: DateTime.formatIso(now), + } + : claudeProviderTurnTokenUsage( + usage, + maxTokens, + DateTime.formatIso(now), + adapterOptions.settings.autoCompactWindow, + context.streamUsage?.requestedAt, + ); + if (typeof usage !== "number") { + if (claudeMessageInputTokens(usage) <= 0) return; + context.compactionTokens = undefined; + } yield* emitProviderEvent({ type: "provider_turn.updated", driver: CLAUDE_PROVIDER, @@ -5349,6 +5668,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* endClaudeMonitorTasks((_taskId, task) => task.nativeThreadId === nativeThreadId); }); + const forgetAbsorbedWake = (nativeThreadId: string) => + Ref.update(absorbedWakes, (current) => { + if (!current.has(nativeThreadId)) return current; + const updated = new Set(current); + updated.delete(nativeThreadId); + return updated; + }); + const clearWakeStateForNativeThread = /* Provider events include streaming deltas; avoid a span for each update. */ Effect.fnUntraced(function* (nativeThreadId: string) { @@ -5369,8 +5696,18 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( updated.delete(nativeThreadId); return updated; }); + yield* forgetAbsorbedWake(nativeThreadId); }); + // Claude runs one root turn at a time, so a user turn's result also ends a wake turn it + // folded in. The continuation replays that wake's buffered frames but gets no result of its own. + const markAbsorbedWake = Effect.fnUntraced(function* (nativeThreadId: string) { + const messages = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []; + const lastResult = messages.findLastIndex((entry) => entry.type === "result"); + if (!messages.slice(lastResult + 1).some(isClaudeRootTurnFrame)) return; + yield* Ref.update(absorbedWakes, (current) => new Set(current).add(nativeThreadId)); + }); + // The SDK emits no initial roster; reset roster and wake eligibility when opening a live query. const resetBackgroundTaskStateForNativeThreadProcess = Effect.fnUntraced(function* ( nativeThreadId: string, @@ -6248,6 +6585,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( SDKMessage, { readonly type: "system"; readonly subtype: "task_notification" } >; + // The process that ran it exited, which confirms the outcome as a native one would. + readonly processExited?: true; readonly onlyIfRunningInRun?: RunId | null; }) { const registeredSubagent = (yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId); @@ -6385,7 +6724,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( task, nativeTaskType: input.nativeTaskType ?? existingSubagent?.nativeTaskType, confirmedOutcome: - input.nativeOutcome !== undefined + input.nativeOutcome !== undefined || input.processExited === true ? confirmedSubagentOutcome(task) : isReopen ? undefined @@ -6919,6 +7258,107 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( registry: target.toolCalls, }); + // App requests never start a CLI; they reuse the live process that ran the tool. + const mcpAppQuery = Effect.fn("ClaudeAdapterV2.mcpAppQuery")(function* ( + providerThread: OrchestrationV2ProviderThread, + ) { + const liveQuery = yield* Ref.get(queryContext); + if ( + liveQuery === null || + liveQuery.stopping || + liveQuery.nativeThreadId !== providerThread.nativeThreadRef?.nativeId + ) { + return yield* new ProviderAdapter.ProviderAdapterSessionStoppedError({ + driver: CLAUDE_PROVIDER, + }); + } + if (!claudeSupportsMcpApps(liveQuery.capabilities)) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "This Claude process does not support MCP Apps.", + }); + } + return liveQuery; + }); + + // Servers can connect after capture, and a forged app reference can name a colliding server. + const mcpAppServerStatuses = Effect.fn("ClaudeAdapterV2.mcpAppServerStatuses")(function* ( + providerThread: OrchestrationV2ProviderThread, + server: string, + ) { + const liveQuery = yield* mcpAppQuery(providerThread); + const statuses = yield* liveQuery.query.mcpServerStatus.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Failed to list Claude MCP tools.", + cause, + }), + ), + ); + if (claudeMcpServerPrefixOverlaps(statuses, server)) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Another MCP server's tool names overlap this app's server.", + }); + } + return { liveQuery, statuses }; + }); + + /** Reads an app tool's `ui://` document into an attachment; a plain tool row is the fallback. */ + const captureClaudeMcpApp = Effect.fn("ClaudeAdapterV2.captureMcpApp")( + function* (input: { + readonly liveQuery: ClaudeLiveQueryContext; + readonly toolCall: ActiveClaudeToolCall; + readonly output: ClaudeNativeToolOutput; + }) { + const { liveQuery, toolCall } = input; + if ( + !toolCall.toolName.startsWith("mcp__") || + toolCall.toolName.startsWith("mcp__t3-code__") || + !claudeSupportsMcpApps(liveQuery.capabilities) + ) { + return undefined; + } + const match = findClaudeMcpTool( + yield* liveQuery.query.mcpServerStatus, + toolCall.toolName, + ); + const resourceUri = mcpAppToolResourceUri(match?.tool._meta); + if (match === undefined || resourceUri === undefined) return undefined; + const { contents } = yield* liveQuery.query.readMcpResource({ + server: match.server, + uri: resourceUri, + }); + const reference = yield* snapshotMcpApp({ + attachmentsDir, + threadId: toolCall.threadId, + server: match.server, + tool: match.tool.name, + resourceUri, + contents, + }).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); + if (reference === undefined) return undefined; + return { + type: "structured_tool_use_result", + value: { + [MCP_APP_OUTPUT_KEY]: reference, + result: claudeMcpCallToolResult(claudeNativeToolOutputValue(input.output)), + }, + } satisfies ClaudeNativeToolOutput; + }, + Effect.timeout(CLAUDE_MCP_APP_CAPTURE_TIMEOUT), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("Failed to capture an MCP app; showing a plain tool row.", { + cause, + }).pipe(Effect.as(undefined)), + ), + Effect.orDie, + ); + const settleToolCall = Effect.fnUntraced(function* (input: { readonly context: ActiveClaudeTurnContext | null; readonly toolCall: ActiveClaudeToolCall; @@ -7674,6 +8114,69 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); }); + const endSubagentsOfClosedQuery = Effect.fnUntraced(function* (input: { + readonly closedQuery: ClaudeLiveQueryContext; + readonly context: ActiveClaudeTurnContext; + readonly subagents: Iterable; + readonly buffered: ReadonlyArray | undefined; + readonly interrupted: boolean; + }) { + for (const [taskId, subagent] of input.subagents) { + if ( + subagent.task.status !== "running" || + input.closedQuery.subagentsFromEarlierProcesses.has(subagent) + ) + continue; + const latestLifecycle = input.buffered?.findLast( + ( + message, + ): message is Extract< + SDKMessage, + { readonly subtype: "task_started" | "task_notification" } + > => + message.type === "system" && + (message.subtype === "task_started" || message.subtype === "task_notification") && + message.task_id === taskId, + ); + const completion = + latestLifecycle?.subtype === "task_notification" ? latestLifecycle : undefined; + yield* updateClaudeSubagentNode({ + context: input.context, + taskId, + status: completion + ? claudeTaskOutcome(completion.status) + : input.interrupted + ? "cancelled" + : "failed", + ...(completion === undefined + ? {} + : { workflowFrame: completion, result: completion.summary }), + processExited: true, + onlyIfRunningInRun: subagent.task.runId, + }); + } + }); + + // A query opened while this one was closing inherited the subagents it ran, so + // nothing else ends them. Leave the live query's own turn, roster and subagents alone. + const endSubagentsOfReplacedQuery = Effect.fnUntraced(function* ( + closedQuery: ClaudeLiveQueryContext, + ) { + const context = closedQuery.lastTurn; + if (context === null) return; + yield* endSubagentsOfClosedQuery({ + closedQuery, + context, + subagents: [...(yield* Ref.get(sessionSubagentsByTaskId))].filter(([, subagent]) => + closedQuery.handedOffSubagents.has(subagent), + ), + buffered: (yield* Ref.get(wakeBuffers)).get(closedQuery.nativeThreadId)?.messages, + interrupted: + closedQuery.stopping || + (yield* Ref.get(interruptedTurns)).has(context.providerTurnId), + }); + }); + const finalizeActiveTurnAfterQueryExit = Effect.fnUntraced(function* ( closedQuery: ClaudeLiveQueryContext, cause?: Cause.Cause, @@ -7705,39 +8208,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* resetBackgroundTaskStateForNativeThreadProcess(closedQuery.nativeThreadId); } // Preserve buffered outcomes before Stop clears the wake buffer. - for (const [taskId, subagent] of subagents) { - if ( - subagent.task.status !== "running" || - closedQuery.subagentsFromEarlierProcesses.has(subagent) - ) - continue; - const latestLifecycle = buffered?.findLast( - ( - message, - ): message is Extract< - SDKMessage, - { readonly subtype: "task_started" | "task_notification" } - > => - message.type === "system" && - (message.subtype === "task_started" || message.subtype === "task_notification") && - message.task_id === taskId, - ); - const completion = - latestLifecycle?.subtype === "task_notification" ? latestLifecycle : undefined; - yield* updateClaudeSubagentNode({ - context, - taskId, - status: completion - ? claudeTaskOutcome(completion.status) - : interrupted - ? "cancelled" - : "failed", - ...(completion === undefined - ? {} - : { workflowFrame: completion, result: completion.summary }), - onlyIfRunningInRun: subagent.task.runId, - }); - } + yield* endSubagentsOfClosedQuery({ + closedQuery, + context, + subagents, + buffered, + interrupted, + }); yield* Ref.update(interruptedTurns, (current) => { const next = new Set(current); next.delete(context.providerTurnId); @@ -7911,6 +8388,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( childThreadId: registered?.childThreadId, }); } + if (message.type === "result") { + bufferedResultArrivals.set(message, yield* DateTime.now); + } + if (isClaudeRootTurnFrame(message)) { + yield* forgetAbsorbedWake(wakeInput.nativeThreadId); + } yield* Ref.update(wakeBuffers, (current) => { const existing = current.get(wakeInput.nativeThreadId); const updated = new Map(current); @@ -8586,38 +9069,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( typeof postTokens === "number" && Number.isFinite(postTokens) && postTokens > 0 ? Math.round(postTokens) : undefined; - if (afterTokenCount !== undefined) { - const maxTokens = claudeContextWindow(context.input.modelSelection); - yield* emitProviderEvent({ - type: "provider_turn.updated", - driver: CLAUDE_PROVIDER, - threadId: context.input.threadId, - providerTurn: { - id: context.providerTurnId, - providerThreadId: context.input.providerThread.id, - nodeId: context.input.rootNodeId, - runAttemptId: context.input.attemptId, - nativeTurnRef: { - driver: CLAUDE_PROVIDER, - nativeId: context.nativeTurnId, - strength: "strong", - }, - ordinal: context.providerTurnOrdinal, - status: "running", - startedAt: context.startedAt, - completedAt: null, - tokenUsage: { - usedTokens: afterTokenCount, - maxTokens, - ...claudeAutoCompactThreshold( - adapterOptions.settings.autoCompactWindow, - maxTokens, - ), - updatedAt: DateTime.formatIso(now), - }, - }, - }); - } + context.compactionTokens = afterTokenCount; + if (afterTokenCount !== undefined) yield* emitContextUsage(context, afterTokenCount); yield* emitProviderEvent({ type: "turn_item.updated", driver: CLAUDE_PROVIDER, @@ -8960,10 +9413,32 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } if (message.type === "system" && message.subtype === "task_notification") { - if ( - !appliedSubagentOutcomes.has(message) && - (yield* Ref.get(sessionSubagentsByTaskId)).has(message.task_id) - ) { + const isRegistered = (yield* Ref.get(sessionSubagentsByTaskId)).has(message.task_id); + // A task whose start is still held for the next wake drain has no + // node to finish yet; hold its outcome behind that start, or the + // drain replays the start alone and the task stays running. + if (!isRegistered) { + yield* Ref.update(wakeBuffers, (current) => { + const entry = current.get(liveQuery.nativeThreadId); + if ( + entry === undefined || + entry.messages.includes(message) || + !entry.messages.some( + (buffered) => + buffered.type === "system" && + buffered.subtype === "task_started" && + buffered.task_id === message.task_id, + ) + ) { + return current; + } + return new Map(current).set(liveQuery.nativeThreadId, { + ...entry, + messages: [...entry.messages, message], + }); + }); + } + if (!appliedSubagentOutcomes.has(message) && isRegistered) { yield* discardBufferedSubagentStarts( liveQuery.nativeThreadId, message.task_id, @@ -9126,13 +9601,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolInput: EMPTY_CLAUDE_NATIVE_TOOL_INPUT, parentToolUseId, })); + const appOutput = + parentToolUseId === null && + toolCall.runId !== null && + !isClaudeToolResultError(toolResult) + ? yield* captureClaudeMcpApp({ liveQuery, toolCall, output }) + : undefined; yield* settleToolCall({ context, toolCall, registry: toolCallsFor(context, toolCall), message, toolResult, - output, + output: + appOutput ?? + (toolCall.toolName.startsWith("mcp__") ? withoutForgedMcpApp(output) : output), completedAt: yield* DateTime.now, }); // Subagents write to the session's shared task store, so their @@ -9321,6 +9804,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } if (message.type === "result") { + const contextUsage = context.streamUsage?.usage ?? context.compactionTokens; + if (resultsReportingNewContextWindow.has(message) && contextUsage !== undefined) { + yield* emitContextUsage(context, contextUsage); + } const completedAt = yield* DateTime.now; const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); const wasSteered = (yield* Ref.get(steeredTurns)).has(context.providerTurnId); @@ -9337,6 +9824,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( next.delete(context.providerTurnId); return next; }); + if (!isClaudeProviderContinuationTurn(context.input)) { + yield* markAbsorbedWake(liveQuery.nativeThreadId); + } const usageLimited = context.authenticationFailureMessage === undefined && (context.rejectedRateLimitTypes.size > 0 || @@ -9381,7 +9871,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message: limitedCompaction.message ?? failureHint, code: "compaction_failed", }) - : providerFailureFromResult(message, completedAt, failureHint, usageLimited); + : providerFailureFromResult( + message, + bufferedResultArrivals.get(message) ?? completedAt, + failureHint, + usageLimited, + ); const terminalFailure = resultFailure?.class === "usage_limit" ? { ...resultFailure, resetAt: resetAt ?? resultFailure.resetAt ?? null } @@ -10241,6 +10736,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, nativeThreadId, + title: turnInput.appThread.title, resume: shouldResume, ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), cwd: turnInput.runtimePolicy.cwd, @@ -10317,8 +10813,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( promptEchoMode: "unknown", openedPermissionMode: queryOptions.permissionMode, receivedInit: false, + capabilities: new Set(), permissionMode: queryOptions.permissionMode, stopping: false, + handedOffSubagents: new Set(), subagentsFromEarlierProcesses: new Set( [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].filter( (subagent) => subagent.task.status === "running", @@ -10332,7 +10830,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( hostPlatform, ), }; - yield* Ref.set(queryContext, context); + // One step, so the replaced process cannot exit between the install and the handoff. + yield* Ref.update(queryContext, (replaced) => { + if (replaced !== null) { + replaced.handedOffSubagents = new Set( + [...context.subagentsFromEarlierProcesses].filter( + (subagent) => !replaced.subagentsFromEarlierProcesses.has(subagent), + ), + ); + } + return context; + }); yield* querySession.messages.pipe( Stream.runForEach((message) => sdkMessageLock.withPermits(1)( @@ -10345,6 +10853,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context.openedPermissionMode = message.permissionMode ?? context.openedPermissionMode; context.receivedInit = true; + context.capabilities = new Set(message.capabilities ?? []); } if ( message.type === "system" && @@ -10362,6 +10871,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( growth === undefined && context.modelLedger !== undefined ? undefined : ledger; + for (const [model, window] of claudeReportedContextWindows( + message.modelUsage, + )) { + if (reportedContextWindows.get(model) === window) continue; + reportedContextWindows.set(model, window); + resultsReportingNewContextWindow.add(message); + } } return handleSdkMessage({ query: querySession, message }); }), @@ -10390,6 +10906,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context.nativeThreadId, context.stopping ? "interrupted" : "failed", ); + } else { + yield* endSubagentsOfReplacedQuery(context); } }), ), @@ -10407,7 +10925,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // Use fresh uuids: native sessions can outlive this database, and Claude skips previously recorded prompts. const promptUuid = isClaudeProviderContinuationTurn(turnInput) ? null - : yield* crypto.randomUUIDv4().pipe( + : yield* random.uuidV4().pipe( Effect.filterOrFail(isClaudePromptUuid), Effect.mapError( (cause) => @@ -10498,6 +11016,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( blocks: new Map(), }, streamUsage: null, + compactionTokens: undefined, toolCalls: new Map(), compaction: null, ignoredTaskIds: new Set(), @@ -10614,6 +11133,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( updated.delete(nativeThreadId); return updated; }); + const wakeAbsorbed = (yield* Ref.get(absorbedWakes)).has(nativeThreadId); + yield* forgetAbsorbedWake(nativeThreadId); if (drained.length === 0) { const completedAt = yield* DateTime.now; yield* finalizeActiveTurn({ context, status: "completed", completedAt }); @@ -10682,7 +11203,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* handleSdkMessage({ query: querySession.query, message: lastResult }); return; } - if (hasOpaqueTaskNotification && !context.nativeWakeStarted) { + if (wakeAbsorbed || (hasOpaqueTaskNotification && !context.nativeWakeStarted)) { const completedAt = yield* DateTime.now; yield* finalizeActiveTurn({ context, status: "completed", completedAt }); } else if (!context.nativeWakeStarted) { @@ -11005,55 +11526,62 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (ownedLiveQuery) { yield* finalizeActiveTurnAfterQueryExit(existing); yield* endSubagentCallsOfClosedQuery(nativeThreadId, "interrupted"); + } else { + yield* endSubagentsOfReplacedQuery(existing); } yield* Deferred.succeed(existing.closed, undefined); }); yield* Effect.addFinalizer(() => closeSession()); // Metadata-only transcripts cannot be resumed or recreated; fail resumeThread to trigger a fresh session and handoff. - const hasEmptyClaudeTranscript = Effect.fn("ClaudeAdapterV2.hasEmptyClaudeTranscript")( - function* (nativeThreadId: string) { - const configuredDir = adapterOptions.environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; - const projectsDir = path.join( - configuredDir.length > 0 - ? path.resolve(session.cwd, configuredDir) - : path.join(yield* HostProcess.HomeDirectory, ".claude"), - "projects", - ); - // Any message in any transcript copy means Claude may still resume it. - let found = false; - for (const project of yield* fileSystem.readDirectory(projectsDir)) { - const transcriptPath = path.join(projectsDir, project, `${nativeThreadId}.jsonl`); - // Stray files beside the project dirs fail with ENOTDIR. - if ( - !(yield* fileSystem.exists(transcriptPath).pipe(Effect.orElseSucceed(() => false))) - ) { - continue; - } - const conversationLine = yield* fileSystem - .stream(transcriptPath) - .pipe( - Stream.decodeText, - Stream.splitLines, - Stream.filter(isClaudeConversationLine), - Stream.runHead, - ); - if (Option.isSome(conversationLine)) { - return false; - } - found = true; + const claudeTranscriptState = Effect.fn("ClaudeAdapterV2.claudeTranscriptState")(function* ( + nativeThreadId: string, + ) { + const configuredDir = adapterOptions.environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; + const projectsDir = path.join( + configuredDir.length > 0 + ? path.resolve(session.cwd, configuredDir) + : path.join(yield* HostProcess.HomeDirectory, ".claude"), + "projects", + ); + // Any message in any transcript copy means Claude may still resume it. + let found = false; + let unreadable = false; + for (const project of yield* fileSystem.readDirectory(projectsDir)) { + const transcriptPath = path.join(projectsDir, project, `${nativeThreadId}.jsonl`); + // Stray files beside the project dirs fail with ENOTDIR. + if ( + !(yield* fileSystem.exists(transcriptPath).pipe(Effect.orElseSucceed(() => false))) + ) { + continue; } - return found; - }, - ); + const conversationLine = yield* fileSystem + .stream(transcriptPath) + .pipe( + Stream.decodeText, + Stream.splitLines, + Stream.filter(isClaudeConversationLine), + Stream.runHead, + Effect.option, + ); + if (Option.isNone(conversationLine)) { + unreadable = true; + continue; + } + if (Option.isSome(conversationLine.value)) { + return "conversation" as const; + } + found = true; + } + return found && !unreadable ? ("empty" as const) : ("missing" as const); + }); const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: adapterOptions.instanceId, driver: CLAUDE_PROVIDER, providerSessionId: input.providerSessionId, providerSession: session, - getModelContextWindow: (selection) => - resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, selection), + getModelContextWindow: (selection) => claudeContextWindow(selection) ?? undefined, events: Stream.fromEffectRepeat(Queue.take(events)), backgroundTasksEndWithProcess: true, hasPendingBackgroundWork: Effect.gen(function* () { @@ -11137,16 +11665,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeThreadId !== undefined && nativeThreadId !== null && // A live process holds the conversation even before saving it. - (yield* Ref.get(queryContext))?.nativeThreadId !== nativeThreadId && - // An unreadable transcript leaves the resume to the CLI. - (yield* hasEmptyClaudeTranscript(nativeThreadId).pipe( - Effect.orElseSucceed(() => false), - )) + (yield* Ref.get(queryContext))?.nativeThreadId !== nativeThreadId ) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CLAUDE_PROVIDER, - detail: `Claude session ${nativeThreadId} has no conversation to resume.`, - }); + // An unreadable transcript leaves the resume to the CLI. + const transcript = yield* claudeTranscriptState(nativeThreadId).pipe( + Effect.orElseSucceed(() => "missing" as const), + ); + if (transcript === "empty") { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: `Claude session ${nativeThreadId} has no conversation to resume.`, + }); + } + // A session imported from the terminal has history but no T3 + // provider turns; opening it with sessionId fails "already in use". + if (transcript === "conversation") { + yield* Ref.update(openedNativeThreads, (current) => + current.has(nativeThreadId) ? current : new Set(current).add(nativeThreadId), + ); + } } const updatedAt = yield* DateTime.now; return { @@ -11176,6 +11713,85 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message: { ...turnInput.message, text: "/compact" }, }), steerTurn, + mcpApps: { + listTools: Effect.fn("ClaudeAdapterV2.listMcpTools")(function* ({ + providerThread, + server, + }) { + const { liveQuery, statuses } = yield* mcpAppServerStatuses(providerThread, server); + const status = statuses.find((candidate) => candidate.name === server); + const rules = yield* liveQuery.query.listPermissionRules.pipe( + Effect.orElseSucceed(() => null), + ); + return (status?.tools ?? []).map((tool) => claudeMcpToolForApps(tool, rules)); + }), + callTool: Effect.fn("ClaudeAdapterV2.callMcpTool")(function* (call) { + const { liveQuery, statuses } = yield* mcpAppServerStatuses( + call.providerThread, + call.server, + ); + const tool = claudeMcpToolName(call.server, call.tool); + const sameName = ( + statuses.find((status) => status.name === call.server)?.tools ?? [] + ).filter((candidate) => claudeMcpToolName(call.server, candidate.name) === tool); + // `refresh.weather` and `refresh_weather` reach Claude as one name, so the checked tool may not be the one run. + if (sameName.length > 1) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Another tool on this MCP server has the same Claude tool name.", + }); + } + // mcp_call skips Claude's permission check, so its deny rules are applied here. + const rules = yield* liveQuery.query.listPermissionRules.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: `Claude's permission rules could not be read, so ${tool} was not called.`, + cause, + }), + ), + ); + if (claudeRulesMayCoverMcpTools(rules, ["deny"])) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: `A Claude deny rule may cover ${tool}, so it was not called.`, + }); + } + return claudeMcpCallToolResult( + yield* liveQuery.query + .callMcpTool({ + tool, + arguments: call.arguments, + }) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Claude MCP tool call failed.", + cause, + }), + ), + ), + ); + }), + readResource: Effect.fn("ClaudeAdapterV2.readMcpResource")(function* (read) { + const { liveQuery } = yield* mcpAppServerStatuses(read.providerThread, read.server); + return yield* liveQuery.query + .readMcpResource({ server: read.server, uri: read.uri }) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Claude MCP resource read failed.", + cause, + }), + ), + ); + }), + }, stopTask: Effect.fn("ClaudeAdapterV2.stopTask")(function* ({ providerThread, taskId }) { const liveQuery = yield* Ref.get(queryContext); const task = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId); @@ -11523,7 +12139,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( export type ClaudeAdapterV2DriverEnv = | ChildProcessSpawner.ChildProcessSpawner | ClaudeAgentSdkQueryRunner - | Crypto.Crypto + | SecureRandom.SecureRandom | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 | McpProviderSessions.McpProviderSessions @@ -11549,7 +12165,7 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( ); const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment); const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; // Coder: the workspace CLI transport runs the configured executable directly. const binaryPath = expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory); return yield* makeClaudeAdapterV2({ @@ -11559,7 +12175,7 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( attachmentsDir: serverConfig.attachmentsDir, fileSystem, path, - crypto, + random, idAllocator, queryRunner, continuationRequests, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index 04021d513776e..64423fd2e4d4c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -5,6 +5,7 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeStream from "node:stream"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; @@ -13,6 +14,7 @@ import * as Exit from "effect/Exit"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; @@ -45,7 +47,7 @@ const realQueryRunner = layerQueryRunner.pipe( canonical: undefined, }), ), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); // Coder: upstream declares this beside its fork tests, which are not carried (see above). @@ -135,6 +137,38 @@ it.effect("reopens a stalled MCP registration before delivering the resumed prom ), ); +it.effect.each([ + { case: "a readable rule list", errors: undefined, readable: true }, + { case: "a settings file Claude skipped", errors: [{ file: "settings.json" }], readable: false }, + { case: "a reply without a rule list", response: {}, readable: false }, +])("reads Claude's permission rules only when no settings file was skipped: $case", (testCase) => + Effect.scoped( + Effect.gen(function* () { + const rules = [{ behavior: "deny" as const, rule: "mcp__probe__redeploy" }]; + const querySpy = vi.spyOn(ClaudeAgentSdk, "query").mockImplementation( + () => + ({ + close: vi.fn(), + request: async () => ({ + response: + "response" in testCase + ? testCase.response + : { state: { rules, errors: testCase.errors } }, + }), + }) as unknown as ClaudeSdk.Query, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); + const runner = yield* ClaudeAgentSdkQueryRunner; + const { mcpServers: _mcpServers, ...options } = resumedMcpQueryInput.options; + const session = yield* runner.open({ ...resumedMcpQueryInput, options }); + yield* Effect.addFinalizer(() => session.close.pipe(Effect.orDie)); + const exit = yield* Effect.exit(session.listPermissionRules); + if (testCase.readable) assert.deepEqual(exit, Exit.succeed(rules)); + else assert.isTrue(Exit.isFailure(exit)); + }).pipe(Effect.provide(realQueryRunner)), + ), +); + it.effect.each([true, false])("only retries after confirmed process exit: %s", (exits) => Effect.scoped( Effect.gen(function* () { @@ -385,6 +419,47 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro }), ); + const rootRefusal = + "--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons"; + + const startupFailureMessage = (stderr: string) => + Effect.gen(function* () { + const cli = fakeClaudeCli(`printf '%s\\n' '${stderr}' >&2\nexit 1`); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(NodePath.dirname(cli), { recursive: true, force: true })), + ); + const session = yield* openFakeCli(cli); + const exit = yield* Stream.runDrain(session.messages).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + return Exit.isFailure(exit) + ? makeProviderFailure({ cause: exit.cause, class: "transport_error" }).message + : ""; + }); + + // Claude Code refuses bypassPermissions as root and explains why only on + // stderr, so the turn failure has to carry that line. + it.effect("reports the CLI's root refusal when it exits before answering", () => + Effect.gen(function* () { + assert.include(yield* startupFailureMessage(rootRefusal), rootRefusal); + }).pipe(Effect.provide(realQueryRunner)), + ); + + // Startup stderr can carry paths, tokens, or settings, so only the known + // refusal reaches the failure the client shows. + it.effect("keeps other startup stderr out of the turn failure", () => + Effect.gen(function* () { + const surrounded = yield* startupFailureMessage( + `token=sk-private-123\n${rootRefusal}\nat /home/private/settings.json`, + ); + assert.include(surrounded, rootRefusal); + assert.notInclude(surrounded, "sk-private-123"); + assert.notInclude(surrounded, "/home/private"); + + const unknown = yield* startupFailureMessage("token=sk-private-123"); + assert.notInclude(unknown, "sk-private-123"); + }).pipe(Effect.provide(realQueryRunner)), + ); + // The CLI keeps running its turn after its pipes close, so a server that // crashed would leave it working next to the session it resumes. it.effect("stops the CLI when the server that started it goes away", () => @@ -439,7 +514,7 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro Layer.provide( Layer.succeed(ProviderEventLoggers, { native: undefined, canonical: undefined }), ), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); return Effect.gen(function* () { const cli = fakeClaudeCli( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index 8da21b20236e2..b70b7322d5e63 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts @@ -20,7 +20,7 @@ const makeTaskFile = Effect.gen(function* () { return { root, tasks, file: path.join(tasks, `${TASK_ID}.output`) }; }); -// TestClock cannot interleave with real filesystem I/O; use the live clock. +// TestClock cannot interleave with real filesystem I/O; tests that wait on a poll use the live clock. describe("tailClaudeTaskOutput", () => { it.live("follows a file that appears after the task starts, across split characters", () => Effect.scoped( @@ -48,6 +48,32 @@ describe("tailClaudeTaskOutput", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("drops a split character when the file is truncated and rewritten", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { root, tasks, file } = yield* makeTaskFile; + yield* fileSystem.makeDirectory(tasks, { recursive: true }); + // End the first read inside the two-byte é. + yield* fileSystem.writeFile(file, new TextEncoder().encode("café").subarray(0, 4)); + const chunks = yield* Queue.unbounded(); + const stop = yield* Deferred.make(); + yield* tailClaudeTaskOutput({ + root, + sessionId: SESSION_ID, + taskId: TASK_ID, + stop, + onChunk: (chunk) => Queue.offer(chunks, chunk), + }).pipe(Effect.forkScoped); + assert.equal(yield* Queue.take(chunks), "caf"); + + yield* fileSystem.writeFileString(file, "ok\n"); + yield* Deferred.succeed(stop, undefined); + assert.equal(yield* Queue.take(chunks), "ok\n"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("skips ahead when the command writes faster than it is read", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts index 0bd369eb4df3a..77505d24a5bbf 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts @@ -88,7 +88,10 @@ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(funct Effect.map((info) => Number(info.size)), Effect.orElseSucceed(() => offset), ); - if (size < offset) offset = 0; + if (size < offset) { + offset = 0; + decoder = new TextDecoder("utf-8", { fatal: false }); + } if (size > offset) { const start = size - offset > MAX_READ_BYTES ? size - MAX_READ_BYTES : offset; const bytes = yield* Effect.scoped( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 5cc3372a4c867..e76e25e491429 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1,8 +1,10 @@ import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { makeProviderTextDeltaCoalescer, type ProviderTextDeltaUpdate, } from "./ProviderTextDeltaCoalescer.ts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, @@ -37,7 +39,7 @@ import * as CodexError from "effect-codex-app-server/errors"; import type * as CodexSchema from "effect-codex-app-server/schema"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Predicate from "effect/Predicate"; import * as FileSystem from "effect/FileSystem"; @@ -69,6 +71,7 @@ import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ModelManifest from "../../provider/ModelManifest.ts"; +import * as PortScanner from "../../preview/PortScanner.ts"; import { resolveProviderCompatibility } from "../../provider/providerCompatibility.ts"; import * as CodexAdapterV2 from "./CodexAdapterV2.ts"; import { withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts"; @@ -835,7 +838,7 @@ describe("CodexAdapterV2 process exit", () => { settings: DEFAULT_CODEX_SETTINGS, environment: {}, clientFactory: { open: () => Effect.succeed(client) }, - crypto: yield* Crypto.Crypto, + digest: yield* Digest.Digest, fileSystem: yield* FileSystem.FileSystem, idAllocator: yield* IdAllocator.IdAllocatorV2, serverConfig: yield* ProviderReplayHarness.makeReplayServerConfig(scenario).pipe( @@ -915,10 +918,75 @@ describe("CodexAdapterV2 process exit", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); + + it.effect("counts the app-server as a running agent until its session closes", () => { + let runningAgents = 0; + const factoryLayer = CodexAdapterV2.layerAppServerClientFactory.pipe( + Layer.provide( + Layer.mock(PortScanner.PortDiscovery)({ + trackAgent: () => { + runningAgents += 1; + return () => { + runningAgents -= 1; + }; + }, + }), + ), + Layer.provide( + Layer.succeed(ProviderEventLoggers.ProviderEventLoggers, { + native: undefined, + canonical: undefined, + }), + ), + Layer.provide( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(999_999_999), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.never, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ), + ), + ), + ); + return Effect.gen(function* () { + const factory = yield* CodexAdapterV2.CodexAppServerClientFactory; + yield* Effect.scoped( + factory + .open({ + instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, + threadId: ThreadId.make("thread-codex-agent"), + providerSessionId: ProviderSessionId.make("session-codex-agent"), + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + settings: DEFAULT_CODEX_SETTINGS, + environment: {}, + }) + .pipe(Effect.tap(() => Effect.sync(() => assert.equal(runningAgents, 1)))), + ); + assert.equal(runningAgents, 0); + }).pipe(Effect.provide(factoryLayer)); + }); }); describe("CodexAdapterV2 session initialize", () => { @@ -964,7 +1032,7 @@ describe("CodexAdapterV2 session initialize", () => { ), ), }, - crypto: yield* Crypto.Crypto, + digest: yield* Digest.Digest, fileSystem: yield* FileSystem.FileSystem, idAllocator: yield* IdAllocator.IdAllocatorV2, serverConfig: yield* ProviderReplayHarness.makeReplayServerConfig(transcript.scenario).pipe( @@ -1032,7 +1100,12 @@ describe("CodexAdapterV2 session initialize", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -1064,7 +1137,12 @@ describe("CodexAdapterV2 session initialize", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -1112,7 +1190,12 @@ describe("CodexAdapterV2 session initialize", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -1158,7 +1241,12 @@ describe("CodexAdapterV2 session initialize", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -1187,6 +1275,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { onRequest: (method: string, params: unknown) => Effect.Effect = () => Effect.void, readChildMetadata?: Parameters[2], beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"], + onUsageLimits?: CodexAdapterV2.CodexAdapterV2Options["onUsageLimits"], ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1244,7 +1333,8 @@ describe("CodexAdapterV2 post-settle continuation", () => { settings: DEFAULT_CODEX_SETTINGS, environment: {}, clientFactory, - crypto: yield* Crypto.Crypto, + ...(onUsageLimits ? { onUsageLimits } : {}), + digest: yield* Digest.Digest, fileSystem, idAllocator, serverConfig, @@ -1415,7 +1505,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -1525,7 +1620,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -1574,7 +1674,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -1740,7 +1845,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -1888,7 +1998,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -2095,7 +2210,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -2204,7 +2324,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -2284,7 +2409,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -2421,7 +2551,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -2532,7 +2667,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -2640,7 +2780,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.deepEqual(resumeThreadEnvs, [harness.threadId, undefined, explicitThreadId]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -2768,7 +2913,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -3198,7 +3348,84 @@ describe("CodexAdapterV2 post-settle continuation", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + + it.effect("reports a thread another Codex client is writing to as in use", () => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "thread-open-elsewhere"; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: "codex-resume-active-writer", + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused-turn", + prompt: "unused-prompt", + }).slice(0, 5), + { + type: "expect_outbound", + label: "thread/resume", + frame: { + id: 3, + method: "thread/resume", + params: { + threadId: nativeThreadId, + excludeTurns: true, + cwd: CODEX_TEST_RUNTIME_POLICY.cwd, + model: CODEX_TEST_MODEL_SELECTION.model, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }, + }, + }, + { + type: "emit_inbound", + label: "active writer", + frame: { + id: 3, + error: { + code: -32600, + message: `thread ${nativeThreadId} already has an active writer`, + }, + }, + }, + ], + }), + ); + + const error = yield* Effect.flip( + harness.runtime.resumeThread({ + providerThread: harness.providerThread, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }), + ); + + assert.equal(error._tag, "ProviderAdapterThreadInUseError"); + assert.nestedPropertyVal(error, "providerThreadId", harness.providerThread.id); + assert.include(error.message, "open in another Codex client"); + assert.nestedPropertyVal( + error, + "cause.errorMessage", + `thread ${nativeThreadId} already has an active writer`, + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -3350,7 +3577,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -3518,7 +3750,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -3651,7 +3888,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -3733,7 +3975,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -3856,7 +4103,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -3888,7 +4140,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -3983,6 +4240,58 @@ describe("CodexAdapterV2 post-settle continuation", () => { ], }); + it.effect("wakes with well-formed text when the cuts split an emoji", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: "codex-bg-wake-surrogate", + entries: [ + ...backgroundExecTranscript.entries.slice(0, -1), + { + type: "emit_inbound", + label: "item/completed/command-late", + frame: { + method: "item/completed", + params: { + item: { + ...backgroundCommandItem("completed"), + command: `${"c".repeat(199)}🚀`, + aggregatedOutput: `🚀${"x".repeat(999)}\n`, + }, + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + completedAtMs: 1782622465500, + }, + }, + }, + ], + }), + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-codex-bg-wake-surrogate"), + text: BG_PROMPT, + }), + ); + yield* harness.until(() => harness.continuationRequests.length > 0); + assert.isTrue(harness.continuationRequests[0]?.detail?.isWellFormed()); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect.each([false, true])( "keeps commands interrupted after native thread closure (persistent tool: %s)", (retainContext) => @@ -4106,7 +4415,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -4262,7 +4576,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -4392,7 +4711,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ), ); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); @@ -4508,7 +4827,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ), ); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ), ); @@ -4791,7 +5110,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -5021,7 +5345,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -5132,7 +5461,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -5258,7 +5592,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -5315,7 +5654,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { Effect.gen(function* () { const nativeThreadId = `native-limit-${scenario.name}`; const nativeTurnId = `turn-limit-${scenario.name}`; - const message = "Provider stopped this request."; + const message = "message" in scenario ? scenario.message : "Provider stopped this request."; const errorInfo = "errorInfo" in scenario ? scenario.errorInfo : scenario.code; const resetAt = "2033-05-19T07:20:00.000Z"; const snapshot = { @@ -5335,6 +5674,26 @@ describe("CodexAdapterV2 post-settle continuation", () => { scenario: `codex-limit-${scenario.name}`, entries: [ ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), + ...("usedBefore" in scenario + ? [ + { + ...snapshot, + frame: { + method: "account/rateLimits/updated", + params: { + rateLimits: { + limitId: "codex", + primary: { + usedPercent: scenario.usedBefore, + resetsAt: 2000100000, + windowDurationMins: 300, + }, + }, + }, + }, + }, + ] + : []), ...(scenario.name === "deferred-reset" ? [snapshot] : []), ...(scenario.name === "deferred-reset" ? [ @@ -5382,7 +5741,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { params: { threadId: nativeThreadId, turnId: nativeTurnId, - willRetry: scenario.name === "retry", + willRetry: scenario.name === "retry" || "willRetry" in scenario, error: { message, codexErrorInfo: errorInfo, @@ -5415,7 +5774,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }, }, - ...(scenario.name === "late-reset" ? [snapshot] : []), + ...(scenario.name === "late-reset" || "resetAfter" in scenario ? [snapshot] : []), ...(scenario.name === "deferred-reset" ? [ { @@ -5453,12 +5812,19 @@ describe("CodexAdapterV2 post-settle continuation", () => { ], }); const resetReceipt = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness(transcript, (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.failure.resetAt === resetAt - ? Deferred.succeed(resetReceipt, undefined) - : Effect.void, + const snapshotReceipt = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness( + transcript, + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.failure.resetAt === resetAt + ? Deferred.succeed(resetReceipt, undefined) + : Effect.void, + undefined, + undefined, + undefined, + () => Deferred.succeed(snapshotReceipt, undefined), ); yield* harness.runtime.startTurn( makeCodexTestTurnInput({ @@ -5478,6 +5844,17 @@ describe("CodexAdapterV2 post-settle continuation", () => { if (scenario.name === "deferred-reset") assert.equal(terminal.failure.resetAt, resetAt); if (scenario.name === "matching-details") assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); + if ("resetAfter" in scenario) { + yield* Deferred.await(snapshotReceipt); + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.failure.resetAt != null, + ), + ); + } if (scenario.name === "late-reset") { yield* Deferred.await(resetReceipt); const item = harness.events.find( @@ -5491,7 +5868,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -5602,10 +5984,104 @@ describe("CodexAdapterV2 post-settle continuation", () => { if (willRetry) assert.equal(terminal.retry?.attempt, 1); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + + // regression: #28032 an unlisted codexErrorInfo dropped the failed turn/completed + it.effect("fails the turn when Codex reports an error info the schema does not list", () => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "native-unlisted-error-info"; + const nativeTurnId = "turn-unlisted-error-info"; + const message = + "The 'gpt-5-codex' model is not supported when using Codex with a ChatGPT account."; + const error = { message, codexErrorInfo: "modelNotFound", additionalDetails: null }; + const usage = { + totalTokens: 1, + inputTokens: 1, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + }; + const transcript = makeCodexReplayTranscript({ + scenario: "codex-unlisted-error-info", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "say hi" }), + { + type: "emit_inbound", + label: "error", + frame: { + method: "error", + params: { threadId: nativeThreadId, turnId: nativeTurnId, willRetry: false, error }, + }, + }, + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: { ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), error }, + }, + }, + }, + // Notifications dispatch in order, so this marks turn/completed as handled. + { + type: "emit_inbound", + label: "usage", + frame: { + method: "thread/tokenUsage/updated", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + tokenUsage: { total: usage, last: usage, modelContextWindow: 200_000 }, + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + text: "say hi", + attemptId: RunAttemptId.make("attempt-unlisted-error-info"), + }), + ); + yield* harness.until( + () => + harness.terminalEvents().length > 0 || + harness.events.some( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage != null, + ), + ); + const terminal = harness.terminalEvents()[0]; + assert.equal(terminal?.status, "failed"); + if (terminal?.status !== "failed") return; + assert.equal(terminal.failure.message, message); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), + ), ); const FAILED_SCENARIO = "codex-failed-mid-command"; @@ -5704,7 +6180,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -5867,7 +6348,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -5934,7 +6420,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -6180,7 +6671,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -6395,7 +6891,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(harness.continuationRequests[0]?.delivery, "message_text"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -6548,7 +7049,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.include(harness.continuationRequests[0]?.detail, "CAPACITY_CHILD_DONE"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -6665,7 +7171,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(harness.subagentUpdates().at(-1)?.subagent.status, "interrupted"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -6737,7 +7248,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -6851,7 +7367,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.include(harness.continuationRequests[0]?.detail, "CHILD_AFTER_GRANDCHILD"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -6929,7 +7450,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -7016,7 +7542,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ]); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -7111,7 +7642,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -7174,7 +7710,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.include(harness.continuationRequests[0]?.detail, "CODEX_RESUME_DONE"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -7333,7 +7874,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { } }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), @@ -7486,7 +8032,127 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("unarchives an unloaded Codex thread before rolling it back", () => + Effect.gen(function* () { + const nativeThreadId = "archived-rollback-thread"; + const resumeParams = { + threadId: nativeThreadId, + excludeTurns: true, + cwd: CODEX_TEST_RUNTIME_POLICY.cwd, + model: CODEX_TEST_MODEL_SELECTION.model, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }; + const thread = codexReplayThreadResult({ nativeThreadId, forkedFromId: null }); + const exchange = ( + id: number, + method: string, + params: unknown, + reply: { readonly result: unknown } | { readonly error: unknown }, + ): Array => [ + { type: "expect_outbound", label: method, frame: { id, method, params } }, + { type: "emit_inbound", label: method, frame: { id, ...reply } }, + ]; + const transcript = makeCodexReplayTranscript({ + scenario: "codex-archived-rollback", + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "archived-rollback-turn", + prompt: "unused", + }).slice(0, 5), + ...exchange( + 3, + "thread/read", + { threadId: nativeThreadId, includeTurns: false }, + { + result: { + thread: { + id: nativeThreadId, + historyMode: "paginated", + status: { type: "notLoaded" }, + }, + }, + }, + ), + ...exchange(4, "thread/resume", resumeParams, { + error: { code: -32600, message: `session ${nativeThreadId} is archived` }, + }), + ...exchange(5, "thread/unarchive", { threadId: nativeThreadId }, { result: thread }), + ...exchange(6, "thread/resume", resumeParams, { result: thread }), + ...exchange( + 7, + "thread/turns/list", + { + threadId: nativeThreadId, + cursor: null, + limit: 1, + sortDirection: "desc", + itemsView: "summary", + }, + { + result: { + data: [{ id: "native-turn-second", items: [], status: "completed", error: null }], + nextCursor: null, + }, + }, + ), + ...exchange( + 8, + "thread/revert", + { threadId: nativeThreadId, beforeTurnId: "native-turn-second" }, + { result: thread }, + ), + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + const now = yield* DateTime.now; + const firstTurn = codexReplaySourceTurn({ + id: "provider-turn-first", + ordinal: 1, + nativeId: "native-turn-first", + providerThreadId: harness.providerThread.id, + now, + }); + const secondTurn = codexReplaySourceTurn({ + id: "provider-turn-second", + ordinal: 2, + nativeId: "native-turn-second", + providerThreadId: harness.providerThread.id, + now, + }); + + const snapshot = yield* harness.runtime.rollbackThread({ + providerThread: harness.providerThread, + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint-archived-rollback"), + appRunOrdinal: 1, + providerTurn: firstTurn, + }, + providerThreadTurns: [firstTurn, secondTurn], + }); + + assert.equal(snapshot.providerThread.nativeThreadRef?.nativeId, nativeThreadId); + assert.deepEqual(snapshot.providerThread.nativeConversationHeadRef, firstTurn.nativeTurnRef); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -7633,11 +8299,94 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); + it.effect("unarchives an archived Codex source thread before forking it", () => + Effect.gen(function* () { + const nativeThreadId = "archived-fork-source"; + const forkThreadId = "archived-fork-target"; + const forkParams = { + threadId: nativeThreadId, + lastTurnId: "native-turn-first", + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }; + const exchange = ( + id: number, + method: string, + params: unknown, + reply: { readonly result: unknown } | { readonly error: unknown }, + ): Array => [ + { type: "expect_outbound", label: method, frame: { id, method, params } }, + { type: "emit_inbound", label: method, frame: { id, ...reply } }, + ]; + const transcript = makeCodexReplayTranscript({ + scenario: "codex-archived-fork", + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "archived-fork-turn", + prompt: "unused", + }).slice(0, 5), + ...exchange(3, "thread/fork", forkParams, { + error: { + code: -32600, + message: `session ${nativeThreadId} is archived. Run \`codex unarchive ${nativeThreadId}\` to unarchive it first.`, + }, + }), + ...exchange( + 4, + "thread/unarchive", + { threadId: nativeThreadId }, + { result: codexReplayThreadResult({ nativeThreadId, forkedFromId: null }) }, + ), + ...exchange(5, "thread/fork", forkParams, { + result: codexReplayThreadResult({ + nativeThreadId: forkThreadId, + forkedFromId: nativeThreadId, + }), + }), + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + const now = yield* DateTime.now; + const firstTurn = codexReplaySourceTurn({ + id: "provider-turn-first", + ordinal: 1, + nativeId: "native-turn-first", + providerThreadId: harness.providerThread.id, + now, + }); + + const forkedProviderThread = yield* harness.runtime.forkThread({ + sourceProviderThread: harness.providerThread, + sourceProviderTurns: [firstTurn], + providerTurnId: firstTurn.id, + targetThreadId: ThreadId.make("thread-archived-fork-target"), + }); + + assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, forkThreadId); + assert.equal(forkedProviderThread.forkedFrom?.providerTurnId, firstTurn.id); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + describe("native goals", () => { const nativeThreadId = "goal-thread"; const objective = "Ship the feature"; @@ -7791,7 +8540,113 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("rejects an oversized /goal objective before any goal request", () => + Effect.gen(function* () { + const oversized = "x".repeat(4001); + const requests: string[] = []; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario: "goal-oversized", entries: sessionStart }), + () => Effect.void, + (method) => + Effect.sync(() => { + requests.push(method); + }), + ); + const start = yield* harness.runtime + .startTurn(yield* goalTurnInput(harness, `/goal ${oversized}`)) + .pipe(Effect.result); + + assert.deepEqual( + requests.filter((method) => method.startsWith("thread/goal/") || method === "turn/start"), + [], + ); + assert.equal(start._tag, "Failure"); + if (start._tag === "Failure") { + const { message } = makeProviderFailure({ cause: start.failure }); + assert.match(message, /4,?001/u); + assert.match(message, /4,?000/u); + } + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("sends a /goal objective at the 4000 character limit unchanged", () => + Effect.gen(function* () { + const atLimit = "y".repeat(4000); + const goal = (status: string) => ({ ...codexGoal(status, 0), objective: atLimit }); + const transcript = makeCodexReplayTranscript({ + scenario: "goal-at-limit", + entries: [ + ...sessionStart, + ...request(3, "thread/goal/get", { threadId: nativeThreadId }, { goal: null }), + ...request( + 4, + "thread/goal/set", + { threadId: nativeThreadId, objective: atLimit, status: "paused" }, + { goal: goal("paused") }, + ), + ...withReplayRequestId( + codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "goal-turn-limit", + prompt: atLimit, + sentPrompt: `Start working toward this goal: ${atLimit}`, + }).slice(5), + 5, + ), + ...request( + 6, + "thread/goal/set", + { threadId: nativeThreadId, status: "active" }, + { goal: goal("active") }, + ), + notification("goal complete", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: "goal-turn-limit", + goal: goal("complete"), + }), + notification("turn done", "turn/completed", { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: "goal-turn-limit", status: "completed" }), + }), + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn(yield* goalTurnInput(harness, `/goal ${atLimit}`)); + yield* harness.firstTerminal; + + assert.deepEqual( + harness.terminalEvents().map((event) => event.status), + ["completed"], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -7883,7 +8738,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -7964,7 +8824,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8027,7 +8892,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8126,7 +8996,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8191,7 +9066,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8281,7 +9161,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8347,7 +9232,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8520,7 +9410,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8557,7 +9452,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8586,7 +9486,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -8647,7 +9552,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, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts index 6e07b695da621..4cf14c195453f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CodexSettings, type ProviderReplayTranscript } from "@t3tools/contracts"; import * as CodexClient from "effect-codex-app-server/client"; @@ -124,7 +125,7 @@ export function layer(input: { const layerServerConfig = Layer.effect( ServerConfig.ServerConfig, makeReplayServerConfig(input.transcript.scenario).pipe(Effect.orDie), - ).pipe(Layer.provide(NodeServices.layer)); + ).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); const layerRegistry = ProviderAdapterRegistry.layerFromAdaptersEffect( CodexAdapterV2.createCodexAdapterV2({ instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, @@ -139,6 +140,7 @@ export function layer(input: { layerReplayClientFactory, layerServerConfig, NodeServices.layer, + NodeCrypto.layer, IdAllocator.layer, ), ), diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 69fdd59fcb5fd..6fbda64166cd3 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -49,6 +49,7 @@ import { import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import { appendGeneratedImageEmbeds } from "@t3tools/shared/generatedImageMarkdown"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { mcpToolResultOutput } from "@t3tools/shared/toolOutput"; import type { ChatAttachment, OrchestrationV2AppThread, @@ -81,7 +82,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexSchema from "effect-codex-app-server/schema"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -340,6 +341,21 @@ function toProtocolError( }); } +const CODEX_THREAD_IN_USE_MESSAGE = + "This conversation is open in another Codex client, such as the Codex app, the Codex CLI, or an IDE extension. Codex lets one client write to a conversation at a time. Quit that client, then send your message again."; + +const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); + +// Codex holds a per-thread writer lock for as long as a thread stays loaded in +// any app-server sharing the same CODEX_HOME, and reports a conflict this way. +function isCodexActiveWriterError(error: unknown): boolean { + return ( + isCodexAppServerRequestError(error) && + error.code === -32600 && + error.errorMessage.includes("already has an active writer") + ); +} + function codexTimestamp(seconds: number | null | undefined): DateTime.Utc { return seconds === null || seconds === undefined ? DateTime.nowUnsafe() @@ -429,17 +445,21 @@ function codexBackgroundCommandDetail(item: { readonly exitCode?: number | null | undefined; readonly aggregatedOutput?: string | null | undefined; }): string { + // Codex rejects a turn/start with a lone surrogate and never answers it, so cut on code points. + let commandEnd = BACKGROUND_COMMAND_DETAIL_COMMAND_MAX_LENGTH; + const finalCommandUnit = item.command.charCodeAt(commandEnd - 1); + if (finalCommandUnit >= 0xd800 && finalCommandUnit <= 0xdbff) commandEnd -= 1; const command = item.command.length > BACKGROUND_COMMAND_DETAIL_COMMAND_MAX_LENGTH - ? `${item.command.slice(0, BACKGROUND_COMMAND_DETAIL_COMMAND_MAX_LENGTH)}...` + ? `${item.command.slice(0, commandEnd)}...` : item.command; const exit = item.exitCode === null || item.exitCode === undefined ? "" : ` (exit ${item.exitCode})`; const output = (item.aggregatedOutput ?? "").trimEnd(); - const outputTail = - output.length > BACKGROUND_COMMAND_DETAIL_OUTPUT_TAIL_MAX_LENGTH - ? `...${output.slice(-BACKGROUND_COMMAND_DETAIL_OUTPUT_TAIL_MAX_LENGTH)}` - : output; + let outputStart = output.length - BACKGROUND_COMMAND_DETAIL_OUTPUT_TAIL_MAX_LENGTH; + const firstOutputUnit = output.charCodeAt(outputStart); + if (firstOutputUnit >= 0xdc00 && firstOutputUnit <= 0xdfff) outputStart += 1; + const outputTail = outputStart > 0 ? `...${output.slice(outputStart)}` : output; const header = `Background command completed${exit}: ${command}`; return outputTail.length === 0 ? header : `${header}\n\nOutput tail:\n${outputTail}`; } @@ -470,9 +490,7 @@ function codexMcpToolOutput( const resultOutput = item.result === null || item.result === undefined ? undefined - : item.result.structuredContent !== null && item.result.structuredContent !== undefined - ? item.result.structuredContent - : item.result.content; + : mcpToolResultOutput(item.result); if (item.error === null || item.error === undefined) { return resultOutput; @@ -510,7 +528,7 @@ export function codexDynamicToolOutput( const projectCodexDynamicToolItem = Effect.fn("CodexAdapterV2.projectDynamicToolItem")(function* ( item: CodexDynamicToolItem, -): Effect.fn.Return { +): Effect.fn.Return { const output = item.type === "mcpToolCall" ? codexMcpToolOutput(item) : codexDynamicToolOutput(item); const toolName = @@ -898,6 +916,28 @@ const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean => turn.status === "failed" || turn.status === "cancelled"); +class CodexGoalObjectiveTooLongError extends Schema.TaggedError()( + "CodexGoalObjectiveTooLongError", + { length: Schema.Number, limit: Schema.Number }, +) { + override get message() { + return `The goal objective is ${this.length} characters; Codex supports at most ${this.limit}. Shorten it and send /goal again. The current goal was not changed.`; + } +} + +function validateCodexGoalObjective(objective: string) { + let length = 0; + for (const _ of objective) length++; + return length > CodexSchema.MAX_THREAD_GOAL_OBJECTIVE_CHARS + ? Effect.fail( + new CodexGoalObjectiveTooLongError({ + length, + limit: CodexSchema.MAX_THREAD_GOAL_OBJECTIVE_CHARS, + }), + ) + : Effect.void; +} + type CodexThreadGoal = CodexSchema.V2ThreadGoalUpdatedNotification["goal"]; const CODEX_GOAL_STATUSES = { @@ -1477,13 +1517,16 @@ function isSensitiveCodexProtocolKey(key: string): boolean { export const layerAppServerClientFactory: Layer.Layer< CodexAppServerClientFactory, never, - ChildProcessSpawner.ChildProcessSpawner | ProviderEventLoggers.ProviderEventLoggers + | ChildProcessSpawner.ChildProcessSpawner + | PortScanner.PortDiscovery + | ProviderEventLoggers.ProviderEventLoggers > = Layer.effect( CodexAppServerClientFactory, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; const agentScope = yield* AgentScope; + const portDiscovery = yield* PortScanner.PortDiscovery; return CodexAppServerClientFactory.of({ open: Effect.fn("CodexAdapterV2.open")(function* (input) { @@ -1511,6 +1554,10 @@ export const layerAppServerClientFactory: Layer.Layer< }), ), ); + // Its shells carry each thread's T3CODE_THREAD_ID, so port discovery reads listeners' threads. + yield* Effect.acquireRelease(Effect.sync(portDiscovery.trackAgent), (release) => + Effect.sync(release), + ); const protocolLogger = makeCodexAppServerProtocolLogger({ nativeEventLogger, threadId: input.threadId, @@ -1535,7 +1582,7 @@ export const layerAppServerClientFactory: Layer.Layer< export type CodexAdapterV2DriverEnv = | CodexAppServerClientFactory - | Crypto.Crypto + | Digest.Digest | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 | McpProviderSessions.McpProviderSessions @@ -1548,7 +1595,7 @@ export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapter ) { const clientFactory = yield* CodexAppServerClientFactory; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const fileSystem = yield* FileSystem.FileSystem; const hostEnvironment = yield* HostProcess.Environment; const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -1579,7 +1626,7 @@ export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapter settings, environment: yield* mergeProviderInstanceEnvironment(environment, hostEnvironment), clientFactory, - crypto, + digest, fileSystem, idAllocator, serverConfig, @@ -1598,7 +1645,7 @@ export interface CodexAdapterV2Options { readonly compatibility?: ( version: string, ) => Effect.Effect; - readonly crypto: Crypto.Crypto; + readonly digest: Digest.Digest["Service"]; readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly serverConfig: ServerConfig["Service"]; @@ -1615,9 +1662,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ) { const mcpSessions = yield* McpProviderSessions.McpProviderSessions; const readMcpSession = (threadId: ThreadId | null) => - // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. - threadId === null ? Effect.succeed(undefined) : mcpSessions.read(threadId); - const { clientFactory, crypto, fileSystem, idAllocator, serverConfig } = adapterOptions; + threadId === null ? Effect.undefined : mcpSessions.read(threadId); + const { clientFactory, digest, fileSystem, idAllocator, serverConfig } = adapterOptions; const continuationRequests = adapterOptions.continuationRequests; return ProviderAdapter.ProviderAdapterV2.of({ @@ -3740,7 +3786,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }); const { ordinal, startedAt } = yield* resolveItemPosition(context, item.id); const projection = yield* projectCodexDynamicToolItem(item).pipe( - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Digest.Digest, digest), ); const node: OrchestrationV2ExecutionNode = { id: nodeId, @@ -4395,8 +4441,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const previous = yield* Ref.getAndUpdate(rateLimitSnapshot, (previous) => mergeCodexRateLimits(previous, payload.rateLimits), ); - const resetAt = codexUsageLimitResetAt(yield* Ref.get(rateLimitSnapshot)); + const snapshot = yield* Ref.get(rateLimitSnapshot); for (const item of (yield* Ref.get(limitedTurnItems)).values()) { + // Coder: no ChatGPT sharing failures (`CodexManagedErrors` is not carried), so every + // usage limit resets with the ordinary allowance. + const resetAt = codexUsageLimitResetAt(snapshot); if (resetAt === null || item.failure.resetAt != null) continue; const updated = { ...item, @@ -6649,6 +6698,24 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ); }); + // Codex refuses to resume a session archived elsewhere until it is unarchived. + const resumeNativeThread = Effect.fn("CodexAdapterV2.resumeNativeThread")(function* ( + threadId: string, + params: Record, + ) { + const resume = client.raw.request("thread/resume", { threadId, ...params }); + return yield* resume.pipe( + Effect.catchTags({ + CodexAppServerRequestError: (cause) => + /\bsession \S+ is archived\b|\bcodex unarchive\b/i.test(cause.errorMessage) + ? client.raw + .request("thread/unarchive", { threadId }) + .pipe(Effect.andThen(resume)) + : Effect.fail(cause), + }), + ); + }); + const startNativeTurn = Effect.fn("CodexAdapterV2.startNativeTurn")( function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, @@ -6737,6 +6804,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, command: OrchestrationV2GoalCommand, ) { + if (command.type === "set") yield* validateCodexGoalObjective(command.objective); const threadId = yield* getNativeThreadId(turnInput.providerThread); rootProviderThreads.set(threadId, turnInput.providerThread); const readGoal = client @@ -7006,8 +7074,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( function* (threadInput) { const nativeThreadId = yield* getNativeThreadId(threadInput.providerThread); const appThreadId = threadInput.threadId ?? threadInput.providerThread.appThreadId; - const resume = client.raw.request("thread/resume", { - threadId: nativeThreadId, + const resume = resumeNativeThread(nativeThreadId, { excludeTurns: true, ...codexThreadRuntimeParams({ threadId: appThreadId, @@ -7021,24 +7088,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }), }); const response = yield* ensureInitialized.pipe( - Effect.andThen( - resume.pipe( - Effect.catchTags({ - CodexAppServerRequestError: (cause) => { - if ( - !/\bsession \S+ is archived\b|\bcodex unarchive\b/i.test( - cause.errorMessage, - ) - ) { - return Effect.fail(cause); - } - return client.raw - .request("thread/unarchive", { threadId: nativeThreadId }) - .pipe(Effect.andThen(resume)); - }, - }), - ), - ), + Effect.andThen(resume), Effect.flatMap(decodeCodexResumeMetadata), ); return { @@ -7057,14 +7107,20 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }, (effect, threadInput) => effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterResumeThreadError({ - driver: CODEX_PROVIDER, - providerSessionId: input.providerSessionId, - providerThreadId: threadInput.providerThread.id, - cause, - }), + Effect.mapError((cause) => + isCodexActiveWriterError(cause) + ? new ProviderAdapter.ProviderAdapterThreadInUseError({ + driver: CODEX_PROVIDER, + providerThreadId: threadInput.providerThread.id, + detail: CODEX_THREAD_IN_USE_MESSAGE, + cause, + }) + : new ProviderAdapter.ProviderAdapterResumeThreadError({ + driver: CODEX_PROVIDER, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), ), ), ), @@ -7792,8 +7848,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } // thread/revert requires the thread loaded in this app-server; resume after restart or idle release. if (!loaded) { - yield* client.raw.request("thread/resume", { - threadId, + yield* resumeNativeThread(threadId, { excludeTurns: true, ...codexThreadRuntimeParams({ threadId: threadInput.providerThread.appThreadId, @@ -7841,24 +7896,32 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( function* (threadInput) { const threadId = yield* getNativeThreadId(threadInput.sourceProviderThread); const boundary = yield* resolveCodexForkBoundary(threadInput); + const fork = client.request("thread/fork", { + threadId, + ...(boundary.lastTurnId === undefined ? {} : { lastTurnId: boundary.lastTurnId }), + ...codexThreadRuntimeParams({ + threadId: threadInput.targetThreadId, + mcpSession: yield* mcpSessions.read(threadInput.targetThreadId), + ...(threadInput.modelSelection === undefined + ? {} + : { modelSelection: threadInput.modelSelection }), + ...(threadInput.runtimePolicy === undefined + ? {} + : { runtimePolicy: threadInput.runtimePolicy }), + }), + }); const response = yield* ensureInitialized.pipe( Effect.andThen( - client.request("thread/fork", { - threadId, - ...(boundary.lastTurnId === undefined - ? {} - : { lastTurnId: boundary.lastTurnId }), - ...codexThreadRuntimeParams({ - threadId: threadInput.targetThreadId, - mcpSession: yield* mcpSessions.read(threadInput.targetThreadId), - ...(threadInput.modelSelection === undefined - ? {} - : { modelSelection: threadInput.modelSelection }), - ...(threadInput.runtimePolicy === undefined - ? {} - : { runtimePolicy: threadInput.runtimePolicy }), + fork.pipe( + Effect.catchTags({ + CodexAppServerRequestError: (cause) => + /\bsession \S+ is archived\b|\bcodex unarchive\b/i.test(cause.errorMessage) + ? client.raw + .request("thread/unarchive", { threadId }) + .pipe(Effect.andThen(fork)) + : Effect.fail(cause), }), - }), + ), ), Effect.mapError( (cause) => diff --git a/apps/server/src/orchestration-v2/AttachmentClaims.ts b/apps/server/src/orchestration-v2/AttachmentClaims.ts index 339d815aba3f7..8a554b888c08c 100644 --- a/apps/server/src/orchestration-v2/AttachmentClaims.ts +++ b/apps/server/src/orchestration-v2/AttachmentClaims.ts @@ -156,17 +156,14 @@ export const claimPendingAttachments = Effect.fn("AttachmentClaims.claimPendingA // bytes exclusively, so a symlink or a file swapped mid-claim never reaches the thread. // Images must carry a PNG, JPEG, or WebP signature matching their declared type. // Like upstream's copy, the pending file stays behind as the retry source. - const bytes = yield* Effect.tryPromise(() => - readStagedAttachment(claim.currentPath, attachment), - ).pipe( - Effect.mapError( - (cause) => - new AttachmentClaimError({ - message: `Attachment '${attachment.name}' cannot be sent: attachment not found or size does not match.`, - cause, - }), - ), - ); + const bytes = yield* Effect.tryPromise({ + try: () => readStagedAttachment(claim.currentPath, attachment), + catch: (cause) => + new AttachmentClaimError({ + message: `Attachment '${attachment.name}' cannot be sent: attachment not found or size does not match.`, + cause, + }), + }); if (normalized.type === "image") { const detectedMimeType = detectImageMimeType(bytes); if ( diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index 5990456e64fdf..fc9d84cddfbee 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -10,7 +10,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; @@ -147,9 +147,9 @@ export class CheckpointServiceV2 extends Context.Service< export const checkpointRefForScopeOrdinal = Effect.fn("checkpointRefForScopeOrdinal")( function* (input: { readonly scopeId: CheckpointScopeId; readonly ordinalWithinScope: number }) { - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const digest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(input.scopeId)) + .digest(Digest.sha256, new TextEncoder().encode(input.scopeId)) .pipe(Effect.orDie); const scopeKey = Hex.encode(digest).slice(0, 32); return CheckpointRef.make( @@ -231,14 +231,14 @@ function makeCheckpoint(input: { export const layer: Layer.Layer< CheckpointServiceV2, never, - CheckpointStore.CheckpointStore | Crypto.Crypto | IdAllocator.IdAllocatorV2 + CheckpointStore.CheckpointStore | Digest.Digest | IdAllocator.IdAllocatorV2 > = Layer.effect( CheckpointServiceV2, Effect.gen(function* () { const checkpointStore = yield* CheckpointStore.CheckpointStore; - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const checkpointRefFor = (input: Parameters[0]) => - checkpointRefForScopeOrdinal(input).pipe(Effect.provideService(Crypto.Crypto, crypto)); + checkpointRefForScopeOrdinal(input).pipe(Effect.provideService(Digest.Digest, crypto)); const idAllocator = yield* IdAllocator.IdAllocatorV2; const workspaceLocks = yield* KeyedLock.make(); const withWorkspaceLock = (cwd: string, effect: Effect.Effect) => diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index bde3af2c2aea9..f1810305b43b8 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -15,7 +15,7 @@ import { type OrchestrationV2DomainEvent, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; @@ -148,7 +148,7 @@ it.effect.each(deliveries)( attachmentsDir: cwd, fileSystem: yield* FileSystem.FileSystem, path: yield* Path.Path, - crypto: yield* Crypto.Crypto, + random: yield* SecureRandom.SecureRandom, idAllocator: yield* IdAllocator.IdAllocatorV2, queryRunner: { allocateSessionId: Effect.succeed(sessionId), @@ -168,6 +168,10 @@ it.effect.each(deliveries)( applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.die("automatic delivery must never interrupt"), close: Effect.void, @@ -478,7 +482,12 @@ it.effect.each(deliveries)( ); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index c4068702e3a57..71d9cf3dadaf9 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -137,6 +137,19 @@ describe("handoff budget", () => { assert.equal(command?.kind, "command_execution"); assert.include(command!.text, "Exit code: 1"); assert.include(command!.text, "界".repeat(300)); + assert.equal( + historicalMessage({ + ...base, + type: "file_change", + fileName: "src/a.ts", + changes: [ + { operation: "update", path: "src/a.ts" }, + { operation: "add", path: "src/b.ts" }, + { operation: "update", path: "src/new.ts", oldPath: "src/old.ts" }, + ], + })?.text, + "File change: src/a.ts, src/b.ts, src/new.ts", + ); assert.isNull( historicalMessage({ ...base, diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts index b5109cb99d930..78958c2e317d5 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts @@ -169,12 +169,12 @@ it.effect("caps the side chat context block without summarizing discarded parent ...importedItem({ role: index % 2 === 0 ? "user" : "assistant", id: `parent-${index}`, - text: `item-${index} ${"x".repeat(300)}`, + text: `item-${index} ${"x".repeat(228)}😀${"x".repeat(70)}`, ordinal: index, }), get text() { summarizedItems++; - return `item-${index} ${"x".repeat(300)}`; + return `item-${index} ${"x".repeat(228)}😀${"x".repeat(70)}`; }, })); const service = yield* ContextHandoffService.ContextHandoffServiceV2; @@ -197,6 +197,7 @@ it.effect("caps the side chat context block without summarizing discarded parent assert.notInclude(context, "item-0 "); assert.include(context, "- Assistant: item-59 "); assert.isAtMost(context.length, 8_000); + assert.isTrue(context.isWellFormed()); assert.isTrue( context.endsWith( `Use t3_thread_read on thread ${parentThreadId} to see the main thread's live state.`, @@ -209,6 +210,36 @@ it.effect("caps the side chat context block without summarizing discarded parent }); assert.isTrue(framingOnly.startsWith("You are in a side chat beside the main thread.")); assert.notInclude(framingOnly, "Main thread activity"); + + const multiFilePatch = ContextHandoffService.makeSideChatContext({ + parentThreadId, + digest: [ + { + id: TurnItemId.make("turn-item:patch"), + threadId: parentThreadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 60, + status: "completed", + title: null, + startedAt: null, + completedAt: null, + updatedAt: DateTime.makeUnsafe(0), + type: "file_change", + fileName: "src/a.ts", + changes: [ + { operation: "update", path: "src/a.ts" }, + { operation: "add", path: "src/b.ts" }, + { operation: "update", path: "src/new.ts", oldPath: "src/old.ts" }, + ], + }, + ], + }); + assert.include(multiFilePatch, "- File change: src/a.ts, src/b.ts, src/new.ts"); }).pipe(Effect.provide(layerTest)), ); diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.ts b/apps/server/src/orchestration-v2/ContextHandoffService.ts index 7eef0afefbc17..10f2701446268 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.ts @@ -109,7 +109,9 @@ function compactText(text: string, maxLength = 240): string { if (compacted.length <= maxLength) { return compacted; } - return `${compacted.slice(0, maxLength - 3)}...`; + const end = maxLength - 3; + const last = compacted.charCodeAt(end - 1); + return `${compacted.slice(0, last >= 0xd800 && last <= 0xdbff ? end - 1 : end)}...`; } export function visibleDeltaRunOrdinals( @@ -211,7 +213,7 @@ function summarizeDeltaItem(item: OrchestrationV2TurnItem): string | null { case "command_execution": return `- Command: ${compactText(item.input)}`; case "file_change": - return `- File change: ${item.fileName}`; + return `- File change: ${compactText(item.changes?.map((change) => change.path).join(", ") ?? item.fileName)}`; case "checkpoint": return `- Checkpoint: ${item.files.length} files`; case "handoff": diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 87f6bbd1a6745..db3a2759e3ac5 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -1,5 +1,6 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it, vi } from "@effect/vitest"; import { @@ -25,6 +26,8 @@ import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; +import * as Tracer from "effect/Tracer"; +import * as TestClock from "effect/testing/TestClock"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ServerConfig from "../config.ts"; @@ -44,12 +47,15 @@ import * as Orchestrator from "./Orchestrator.ts"; import { continueRestartedRun } from "./RestartContinuation.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderContinuationService from "./ProviderContinuationService.ts"; const layerPlatformTest = Layer.merge( - NodeServices.layer, + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ resolveLink: () => Effect.die("unused title link"), }), @@ -150,13 +156,19 @@ const layerTest = Layer.mergeAll( Layer.provide(layerPlatformTest), ); +// ProviderContinuationService consumes the orchestrator's continuation queue, as in production. +const layerContinuationWorkerTest = ProviderContinuationService.layer.pipe( + Layer.provide(Layer.merge(ProviderContinuationRequests.layer, IdAllocator.layer)), + Layer.provideMerge(layerTest), +); + const seedParentWithTerminalTask = (input: { readonly threadId: ThreadId; readonly projectId: ProjectId; readonly runId: RunId; readonly rootNodeId: NodeId; readonly taskId: NodeId; - readonly deliveryState: "delivered" | "claimed" | "acknowledged" | "disposed"; + readonly deliveryState: "pending" | "delivered" | "claimed" | "acknowledged" | "disposed"; readonly completionWake?: "always" | "settled_only"; readonly deliveryTaskIds?: ReadonlyArray; readonly parentRunStatus?: "running" | "completed"; @@ -1047,6 +1059,7 @@ it.layer(layerTest)("delegated completion delivery repairs", (it) => { { command: "acknowledge", before: "running", after: "failed", capture: false }, { command: "acknowledge", before: "waiting", after: "completed", capture: true }, { command: "accept", before: "waiting", after: "completed", capture: true }, + { command: "wake-policy", before: "running", after: "completed", capture: true }, ] as const)( "keeps a $after run that settled between $command's read and commit", ({ command, before, after, capture }) => @@ -1060,14 +1073,20 @@ it.layer(layerTest)("delegated completion delivery repairs", (it) => { const taskId = NodeId.make(`node:${race}-task`); const checkpointId = CheckpointId.make(`checkpoint:${race}`); const messageId = MessageId.make(`message:delegated-delivery:${threadId}`); + const commandType = { + acknowledge: "delegated_task.completion-delivery.acknowledge", + accept: "notification.delivery.accept", + "wake-policy": "delegated_task.wake-policy", + }[command]; yield* seedParentWithTerminalTask({ threadId, projectId: ProjectId.make(`project:${race}`), runId, rootNodeId: NodeId.make(`node:${race}-root`), taskId, - deliveryState: "claimed", - deliveryTaskIds: [taskId], + ...(command === "wake-policy" + ? { deliveryState: "pending" } + : { deliveryState: "claimed", deliveryTaskIds: [taskId] }), now, }); const parent = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; @@ -1108,18 +1127,18 @@ it.layer(layerTest)("delegated completion delivery repairs", (it) => { const { delegatedCompletion: _cohort, ...settledRun } = parent; const commit = sink.commitCommand; let settled = false; + let planned: OrchestrationV2Run["delegatedCompletion"]; yield* Effect.acquireUseRelease( Effect.sync(() => vi.spyOn(sink, "commitCommand").mockImplementation((input) => Effect.gen(function* () { - if ( - !settled && - input.commandType === - (command === "accept" - ? "notification.delivery.accept" - : "delegated_task.completion-delivery.acknowledge") - ) { + if (!settled && input.commandType === commandType) { settled = true; + const plannedRun = input.events.find((event) => event.type === "run.updated"); + planned = + plannedRun?.type === "run.updated" + ? plannedRun.payload.delegatedCompletion + : undefined; // The provider turn ends (or its checkpoint lands) after the command read the run. yield* commit({ commandId: CommandId.make(`command:${race}`), @@ -1157,13 +1176,21 @@ it.layer(layerTest)("delegated completion delivery repairs", (it) => { threadId, messageId, } - : { - type: "delegated_task.completion-delivery.acknowledge", - commandId: CommandId.make(`command:${race}:task-status`), - parentThreadId: threadId, - taskId, - observedByRunId: runId, - }, + : command === "wake-policy" + ? { + type: "delegated_task.wake-policy", + commandId: CommandId.make(`command:${race}:wake-policy`), + parentThreadId: threadId, + taskId, + completionWake: "always", + } + : { + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make(`command:${race}:task-status`), + parentThreadId: threadId, + taskId, + observedByRunId: runId, + }, ), (spy) => Effect.sync(() => spy.mockRestore()), ); @@ -1171,12 +1198,22 @@ it.layer(layerTest)("delegated completion delivery repairs", (it) => { const finished = yield* orchestrator.getThreadProjection(threadId); assert.equal( finished.subagents.find((row) => row.id === taskId)?.completionDelivery?.state, - command === "accept" ? "delivered" : "acknowledged", + { acknowledge: "acknowledged", accept: "delivered", "wake-policy": "claimed" }[command], ); const run = finished.runs.find((candidate) => candidate.id === runId)!; assert.deepEqual( - { status: run.status, completedAt: run.completedAt, checkpointId: run.checkpointId }, - { status: after, completedAt: now, checkpointId: capture ? checkpointId : null }, + { + status: run.status, + completedAt: run.completedAt, + checkpointId: run.checkpointId, + delegatedCompletion: run.delegatedCompletion, + }, + { + status: after, + completedAt: now, + checkpointId: capture ? checkpointId : null, + delegatedCompletion: planned, + }, ); }), ); @@ -1343,9 +1380,324 @@ const seedRestartCancelledChild = (input: { return { taskId, childThreadId, childRunId }; }); +it.layer(layerContinuationWorkerTest)("interrupted completion wakes", (it) => { + it.effect.each([ + { name: "an interrupt keeps", holdQueue: false, siblingFinishesFirst: false }, + { name: "Stop disposes", holdQueue: true, siblingFinishesFirst: false }, + { name: "an interrupt reserves", holdQueue: false, siblingFinishesFirst: true }, + ])("$name a completion wake's other siblings", ({ holdQueue, siblingFinishesFirst }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const key = holdQueue + ? "sibling-stop" + : siblingFinishesFirst + ? "sibling-finished-interrupt" + : "sibling-interrupt"; + const threadId = ThreadId.make(`${key}-parent`); + const originRunId = RunId.make(`${key}-origin`); + const incomingTaskId = NodeId.make(`${key}-incoming`); + yield* seedParentWithTerminalTask({ + threadId, + projectId: ProjectId.make(`${key}-project`), + runId: originRunId, + rootNodeId: NodeId.make(`${key}-origin-root`), + taskId: incomingTaskId, + deliveryState: "claimed", + deliveryTaskIds: [incomingTaskId], + completionWake: "always", + now, + }); + const originRootId = NodeId.make(`${key}-origin-root`); + yield* sink.write({ + events: [ + { + id: EventId.make(`event:${key}-origin-root`), + type: "node.updated", + threadId, + runId: originRunId, + nodeId: originRootId, + occurredAt: now, + payload: { + id: originRootId, + threadId, + runId: originRunId, + parentNodeId: null, + rootNodeId: originRootId, + kind: "root_turn", + status: "running", + countsForRun: true, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }, + ], + }); + yield* orchestrator.dispatch({ + type: "delegated_task.request", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make(`${key}-sibling`), + parentThreadId: threadId, + parentRunId: originRunId, + parentNodeId: originRootId, + task: "Keep working", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + completionWake: "always", + }); + const seeded = yield* orchestrator.getThreadProjection(threadId); + const origin = seeded.runs.find((run) => run.id === originRunId)!; + const sibling = seeded.subagents.find((task) => task.id !== incomingTaskId)!; + yield* sink.write({ + events: [ + { + ...runEvent({ threadId, runId: originRunId, ordinal: 1, status: "completed", now }), + payload: { ...origin, status: "completed", completedAt: now }, + }, + ], + }); + const delivery = origin.delegatedCompletion!.delivery!; + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "server", + commandId: CommandId.make(`${key}-wake`), + threadId, + messageId: delivery.messageId, + text: "Delegated task completed", + attachments: [], + modelSelection, + dispatchMode: { type: "queue_after_active" }, + delegatedCompletion: { + parentRunId: originRunId, + generation: delivery.generation, + taskIds: delivery.taskIds, + }, + }); + yield* orchestrator.resumeQueuedRuns; + const wake = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === delivery.messageId, + )!; + const childRun = (yield* orchestrator.getThreadProjection(sibling.childThreadId!)).runs[0]!; + const completeSibling = Effect.gen(function* () { + const afterSequence = yield* sink.latestSequence(); + yield* sink.write({ + events: [ + { + ...runEvent({ + threadId: sibling.childThreadId!, + runId: childRun.id, + ordinal: childRun.ordinal, + status: "completed", + now, + }), + payload: { ...childRun, status: "completed", completedAt: now }, + }, + ], + }); + return yield* sink.stream({ afterSequence, eventType: "subagent.updated" }).pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === sibling.id && + stored.event.payload.status === "completed", + ), + Stream.take(1), + Stream.runDrain, + Effect.as(afterSequence), + ); + }); + if (siblingFinishesFirst) { + yield* completeSibling; + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).subagents.find( + (task) => task.id === sibling.id, + )?.completionDelivery?.state, + "pending", + ); + } + const beforeInterrupt = yield* sink.latestSequence(); + const interrupted = yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`${key}-interrupt`), + threadId, + runId: wake.id, + ...(holdQueue ? { holdQueue } : {}), + }); + // The wake's terminal may claim the sibling right after commit, so read what the interrupt + // itself wrote rather than a later projection. + const interruptDelivery = new Map( + interrupted.storedEvents.flatMap(({ event }) => + event.type === "subagent.updated" + ? [[event.payload.id, event.payload.completionDelivery?.state] as const] + : [], + ), + ); + const interruptCohort = interrupted.storedEvents.flatMap(({ event }) => + event.type === "run.updated" && event.payload.id === originRunId + ? [event.payload.delegatedCompletion] + : [], + ); + assert.equal(interruptDelivery.get(incomingTaskId), "disposed"); + assert.equal(interruptCohort.at(-1)?.disposition, holdQueue ? "stopped" : "open"); + if (holdQueue) { + assert.equal(interruptDelivery.get(sibling.id), "disposed"); + return; + } + assert.isFalse(interruptDelivery.has(sibling.id)); + + const afterSequence = siblingFinishesFirst ? beforeInterrupt : yield* completeSibling; + // The wake's terminal, or the sibling's own completion, reserves the successor. + yield* sink.stream({ afterSequence, eventType: "subagent.updated" }).pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === sibling.id && + stored.event.payload.completionDelivery?.state === "claimed", + ), + Stream.take(1), + Stream.runDrain, + ); + const woken = yield* orchestrator.getThreadProjection(threadId); + const successor = woken.runs.find((run) => run.id === originRunId)?.delegatedCompletion + ?.delivery; + assert.equal(successor?.generation, delivery.generation + 1); + assert.deepEqual(successor?.taskIds, [sibling.id]); + assert.notEqual(successor?.messageId, delivery.messageId); + + // ProviderContinuationService consumes the claim's offer and dispatches the successor. + yield* sink.stream({ afterSequence, eventType: "message.updated" }).pipe( + Stream.filter( + (stored) => + stored.event.type === "message.updated" && + stored.event.payload.id === successor!.messageId, + ), + Stream.take(1), + Stream.runDrain, + ); + // Repeated recovery re-offers every open delivery; none may wake the parent twice. + yield* orchestrator.resumeQueuedRuns; + yield* orchestrator.recoverDelegatedTasks; + yield* orchestrator.recoverDelegatedTasks; + yield* orchestrator.resumeQueuedRuns; + const delivered = yield* orchestrator.getThreadProjection(threadId); + const cohort = delivered.runs.find((run) => run.id === originRunId)?.delegatedCompletion; + assert.equal(cohort?.disposition, "open"); + assert.equal(cohort?.nextGeneration, delivery.generation + 2); + assert.deepEqual(cohort?.delivery, successor); + assert.deepEqual( + delivered.messages + .filter((message) => message.delegatedCompletion?.parentRunId === originRunId) + .map((message) => [message.id, message.delegatedCompletion]), + [ + [ + delivery.messageId, + { + parentRunId: originRunId, + generation: delivery.generation, + taskIds: [incomingTaskId], + }, + ], + [ + successor!.messageId, + { parentRunId: originRunId, generation: successor!.generation, taskIds: [sibling.id] }, + ], + ], + ); + const successorRuns = delivered.runs.filter( + (run) => run.userMessageId === successor!.messageId, + ); + assert.equal(successorRuns.length, 1); + assert.equal(successorRuns[0]?.status, "starting"); + assert.equal( + delivered.subagents.find((task) => task.id === sibling.id)?.completionDelivery?.state, + "claimed", + ); + }), + ); +}); + it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( "subagent follow-ups", (it) => { + it.effect("recovers a reserved delivery without loading conversation history", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:bounded-delivery-recovery"); + const runId = RunId.make("run:bounded-delivery-recovery"); + const taskId = NodeId.make("task:bounded-delivery-recovery"); + yield* seedParentWithTerminalTask({ + threadId, + runId, + taskId, + projectId: ProjectId.make("project:bounded-delivery-recovery"), + rootNodeId: NodeId.make("node:bounded-delivery-recovery"), + deliveryState: "claimed", + deliveryTaskIds: [taskId], + completionWake: "always", + now, + }); + for (const suffix of ["first", "second"]) { + yield* sink.write({ + events: [ + { + id: EventId.make(`event:bounded-delivery-history:${suffix}`), + type: "message.updated", + threadId, + occurredAt: now, + payload: { + id: MessageId.make(`message:bounded-delivery-history:${suffix}`), + threadId, + runId: null, + nodeId: null, + createdBy: "user", + creationSource: "web", + role: "user", + text: "Unrelated historical input", + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }, + }, + ], + }); + } + const statements: string[] = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (time, exit) => { + end(time, exit); + const query = span.attributes.get("db.query.text"); + if (typeof query === "string") statements.push(query); + }; + return span; + }, + }); + yield* orchestrator.recoverDelegatedTasks.pipe(Effect.withTracer(tracer)); + assert.isFalse(statements.some((query) => query.includes("MIN(ordinal)"))); + const messageReads = statements.filter((query) => + query.includes("FROM orchestration_v2_projection_messages"), + ); + assert.isNotEmpty(messageReads); + assert.isTrue(messageReads.every((query) => query.includes("message_id IN"))); + const wake = yield* requests.take; + assert.equal(wake.delegatedCompletion?.parentRunId, runId); + }), + ); it.effect("keeps a checkpoint completion committed between finalize's read and write", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -2048,6 +2400,111 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( }), ); + it.effect("tracks a child's usage-limit auto-resume as a follow-up", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const parentThreadId = ThreadId.make("thread:limit-resume-parent"); + const projectId = ProjectId.make("project:limit-resume"); + const parentRunId = RunId.make("run:limit-resume-parent"); + const rootNodeId = NodeId.make("node:limit-resume-parent"); + yield* seedParentWithTerminalTask({ + threadId: parentThreadId, + projectId, + runId: parentRunId, + rootNodeId, + taskId: NodeId.make("node:limit-resume-seed"), + deliveryState: "acknowledged", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId, + projectId, + parentRunId, + rootNodeId, + name: "limit-resume-child", + completionWake: "always", + continuationPending: false, + runStatus: "completed", + now, + }); + const childRun = (yield* orchestrator.getThreadProjection(child.childThreadId)).runs[0]!; + yield* eventSink.write({ + commandId: reconcileCommandId("limit-resume-child-limited"), + events: [ + { + id: EventId.make("event:limit-resume-child-failed"), + type: "run.updated", + threadId: child.childThreadId, + runId: childRun.id, + occurredAt: now, + payload: { ...childRun, status: "failed", completedAt: now }, + }, + { + id: EventId.make("event:limit-resume-child-error"), + type: "turn-item.updated", + threadId: child.childThreadId, + occurredAt: now, + payload: { + id: TurnItemId.make("turn-item:limit-resume-child-error"), + type: "error", + threadId: child.childThreadId, + runId: childRun.id, + nodeId: childRun.rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "failed", + title: "Usage limit reached", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "usage_limit", + message: "Plan limit reached.", + code: "usageLimitExceeded", + retryable: null, + resetAt: DateTime.formatIso(DateTime.add(now, { minutes: 1 })), + }, + }, + }, + ], + }); + yield* orchestrator.recoverDelegatedTasks; + const childShell = Effect.map(orchestrator.getShellSnapshot(), (snapshot) => + snapshot.threads.find((thread) => thread.id === child.childThreadId), + ); + const arm = limitRecoveryCommand((yield* childShell)!, true, DateTime.toEpochMillis(now)); + assert(arm?.type === "thread.metadata.update" && arm.limitRecovery != null); + yield* orchestrator.autoArmLimitRecovery({ + commandId: arm.commandId, + threadId: arm.threadId, + limitRecovery: arm.limitRecovery, + }); + yield* TestClock.adjust("1 minute"); + const resume = limitRecoveryCommand( + (yield* childShell)!, + true, + DateTime.toEpochMillis(yield* DateTime.now), + ); + assert(resume?.type === "message.dispatch"); + yield* orchestrator.dispatch(resume); + + const resumed = (yield* orchestrator.getThreadProjection(child.childThreadId)).runs.find( + (run) => run.userMessageId === resume.messageId, + ); + assert.isDefined(resumed?.delegatedTaskId); + const followup = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === resumed?.delegatedTaskId, + ); + assert.equal(followup?.childThreadId, child.childThreadId); + assert.isNull(followup?.result); + }), + ); + it.effect("serializes a recovered child answer with its parent and tracks the follow-up", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 6a951e52852a8..7d210f7678675 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -271,6 +271,10 @@ export const layerExecutor: Layer.Layer< const message = projection.messages.find((item) => item.id === messageId); const run = projection.runs.find((item) => item.id === message?.runId); if (message === undefined || run === undefined) return yield* error; + // Notifications must be queued messages. + const queued = + message.delegatedCompletion !== undefined || + message.notification !== undefined; // Reuse message ids and receipts so retries cannot duplicate messages or follow-ups. yield* threads .dispatch({ @@ -285,10 +289,7 @@ export const layerExecutor: Layer.Layer< ? {} : { modelSelection: run.modelSelection }), dispatchMode: { - type: - message.delegatedCompletion === undefined - ? "start_immediately" - : "queue_after_active", + type: queued ? "queue_after_active" : "start_immediately", }, createdBy: message.createdBy, creationSource: message.creationSource, diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index 9d261469c20db..475e0a0e4ae7b 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -27,6 +27,7 @@ import { TurnItemId, } from "@t3tools/contracts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Arr from "effect/Array"; import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -61,6 +62,7 @@ import * as EventSink from "./EventSink.ts"; import * as EventStore from "./EventStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; +import * as ProjectionReplay from "./testkit/ProjectionReplay.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; @@ -84,6 +86,9 @@ const layerCommandReceiptStoreProvided = CommandReceiptStore.layer.pipe( ); const layerIsolatedOutbox = Layer.fresh(EffectOutbox.layer.pipe(Layer.provideMerge(layerDatabase))); const layerProjectionMaintenanceProvided = ProjectionMaintenance.layer.pipe( + Layer.provide(layerDatabase), +); +const layerProjectionReplayProvided = ProjectionReplay.layer.pipe( Layer.provide(layerStoresProvided), ); const layerTest = Layer.mergeAll( @@ -93,6 +98,7 @@ const layerTest = Layer.mergeAll( layerCommandReceiptStoreProvided, IdAllocator.layer, layerProjectionMaintenanceProvided, + layerProjectionReplayProvided, ); const providerInstanceId = ProviderInstanceId.make("codex"); @@ -147,85 +153,6 @@ function threadCreatedEvent(input: { }; } -it.effect("rebuilds event history one bounded page at a time", () => - Effect.gen(function* () { - const eventStore = yield* EventStore.EventStoreV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread:foundation-paged-rebuild"); - const thread = makeThread(threadId, now); - const eventCount = 1_005; - yield* eventStore.append({ - events: [ - threadCreatedEvent({ id: "event:paged-rebuild:0", thread, now }), - ...Array.from({ length: eventCount - 1 }, (_, index) => ({ - id: EventId.make(`event:paged-rebuild:${index + 1}`), - type: "thread.metadata-updated" as const, - threadId, - occurredAt: now, - payload: { ...thread, title: `Rebuilt update ${index + 1}` }, - })), - ], - }); - let applied = 0; - let failAfterFirstPage = false; - const appliedAtRead: Array = []; - const layerObservedStores = Layer.mergeAll( - Layer.succeed(EventStore.EventStoreV2, { - ...eventStore, - read: (input) => - Stream.suspend(() => { - appliedAtRead.push(applied); - if (failAfterFirstPage && applied >= 500) { - return Stream.fail( - new EventStore.EventStoreReadEventsError({ afterSequence: input?.afterSequence }), - ); - } - return eventStore.read(input); - }), - }), - Layer.succeed(ProjectionStore.ProjectionStoreV2, { - ...projectionStore, - apply: (event) => - projectionStore.apply(event).pipe( - Effect.tap(() => - Effect.sync(() => { - applied += 1; - }), - ), - ), - }), - ); - const rebuild = Effect.gen(function* () { - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; - return yield* maintenance.rebuild; - }).pipe( - Effect.provide( - Layer.fresh(ProjectionMaintenance.layer.pipe(Layer.provide(layerObservedStores))), - ), - ); - const rebuilt = yield* rebuild; - assert.isTrue(rebuilt.valid); - assert.equal(applied, eventCount); - assert.deepEqual(appliedAtRead, [0, 500, 1_000]); - assert.equal( - (yield* projectionStore.getThreadProjection(threadId)).thread.title, - "Rebuilt update 1004", - ); - applied = 0; - failAfterFirstPage = true; - const failed = yield* Effect.exit(rebuild); - assert.equal(failed._tag, "Failure"); - assert.equal(applied, 500); - assert.equal( - (yield* projectionStore.getThreadProjection(threadId)).thread.title, - "Rebuilt update 1004", - ); - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; - assert.isTrue((yield* maintenance.verify).valid); - }).pipe(Effect.provide(layerTest)), -); - const seedCompaction = Effect.fnUntraced(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`INSERT INTO orchestration_v2_legacy_imports ( @@ -239,6 +166,13 @@ const seedCompaction = Effect.fnUntraced(function* () { ) SELECT 'event-' || n, 'thread', 'thread', n, CASE WHEN n IN (1, 51, 101) THEN 'message.updated' ELSE 'thread.visited' END, '2026-10-17', 'server', '{"id":"message"}', '{}', 2 FROM history`; + // A pass a day ago marked the whole log; projections cover it. + yield* sql`INSERT INTO orchestration_v2_projection_metadata ( + projection_name, schema_version, last_sequence, updated_at + ) VALUES + ('event-log-prune-mark', 2, 151, '1969-12-30T00:00:00.000Z'), + ('thread-projections', 2, 151, '1969-12-30T00:00:00.000Z') + ON CONFLICT(projection_name) DO UPDATE SET last_sequence = excluded.last_sequence`; yield* sql`WITH RECURSIVE history(n) AS ( SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 100 ) INSERT INTO orchestration_command_receipts ( @@ -283,11 +217,12 @@ it.effect("discovers on the reader worker and deletes on the writer", () => }), ); const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; - const result = yield* maintenance.compactEventStore(); - assert.equal(result.deletedEventCount, 148); + const result = yield* maintenance.pruneEventStore(); + assert.equal(result.deletedEventCount, 150); assert.equal(result.deletedReceiptCount, 100); + // Only the in-transaction check for rows appended during the pass reads on the writer. assert.deepEqual( - reads.filter((query) => /\bSELECT\b/.test(query)), + reads.filter((query) => /\bSELECT\b/.test(query) && !query.includes("AS prompt_sequence")), [], ); assert(writes.some((query) => query.includes("DELETE FROM orchestration_events"))); @@ -295,7 +230,6 @@ it.effect("discovers on the reader worker and deletes on the writer", () => }).pipe( Effect.provide( ProjectionMaintenance.layer.pipe( - Layer.provide(Layer.merge(EventStore.layer, ProjectionStore.layer)), Layer.provideMerge(SqlitePersistence.layerFromPath(filename)), ), ), @@ -317,7 +251,7 @@ it.effect("paces scheduled compaction while preserving retention state and fixed const tracer = Tracer.make({ span(options) { const span = new Tracer.NativeSpan(options); - if (span.name === "ProjectionMaintenance.compactEventStore") compactionSpans.push(span); + if (span.name === "ProjectionMaintenance.pruneEventStore") compactionSpans.push(span); const end = span.end.bind(span); span.end = (time, exit) => { end(time, exit); @@ -376,15 +310,13 @@ it.effect("paces scheduled compaction while preserving retention state and fixed }, }), ); - assert.deepEqual(pauses, Array(5).fill(270)); + assert.deepEqual(pauses, Array(8).fill(270)); assert.deepEqual( compactionSpans.map((span) => span.status._tag), ["Ended"], ); + // 151 stopped being the stream head when 152 committed mid-pass; 152 is past the bound. assert.deepEqual(yield* sql`SELECT sequence FROM orchestration_events ORDER BY sequence`, [ - { sequence: 1 }, - { sequence: 101 }, - { sequence: 151 }, { sequence: 152 }, ]); assert.deepEqual(yield* sql`SELECT command_id FROM orchestration_command_receipts`, [ @@ -767,7 +699,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const now = yield* DateTime.now; const firstThreadId = ThreadId.make("thread:foundation-shared-session:first"); const secondThreadId = ThreadId.make("thread:foundation-shared-session:second"); @@ -828,7 +760,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { }), ); - it.effect("compacts superseded state events, imported v1 events, and legacy receipts", () => + it.effect("prunes covered history, imported v1 events, and legacy receipts", () => Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; @@ -982,9 +914,12 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { ON CONFLICT(command_id) DO NOTHING `; - const summary = yield* maintenance.compactEventStore(); + // The first pass only marks the head and drops imported legacy rows. + const marked = yield* maintenance.pruneEventStore(); + yield* TestClock.adjust("1 day"); + const summary = yield* maintenance.pruneEventStore(); assert.isAtLeast(summary.deletedEventCount, 504); - assert.isAtLeast(summary.deletedReceiptCount, 1); + assert.equal(marked.deletedReceiptCount + summary.deletedReceiptCount, 1); const remaining = yield* sql<{ readonly event_id: string }>` SELECT event_id @@ -995,18 +930,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { `; assert.deepEqual( remaining.map((row) => row.event_id), - [ - "event:foundation-compact:create", - "event:foundation-compact:meta", - "event:foundation-compact:message-1", - "event:foundation-compact:node-1", - "event:foundation-compact:item-1", - "event:foundation-compact:visit-2", - "event:foundation-compact:message-2", - "event:foundation-compact:node-2", - "event:foundation-compact:item-2", - "event:foundation-compact:tags-2", - ], + ["event:foundation-compact:create", "event:foundation-compact:tags-2"], ); const remainingV1 = yield* sql<{ readonly stream_id: string }>` @@ -1030,8 +954,6 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { ["command:foundation-compact:pending"], ); - assert.isTrue((yield* maintenance.verify).valid); - assert.isTrue((yield* maintenance.rebuild).valid); assert.deepEqual(yield* projections.getThreadProjection(threadId), beforeCompaction); }), ); @@ -1040,7 +962,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const sql = yield* SqlClient.SqlClient; const now = yield* DateTime.now; const parentThreadId = ThreadId.make("thread:foundation-cross-thread:parent"); @@ -1356,9 +1278,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { rows.push(eventRow(++ordinal, { commandId: exactPageCommandId })); } const inserted = yield* Effect.forEach( - Array.from({ length: Math.ceil(rows.length / 400) }, (_, chunk) => - rows.slice(chunk * 400, (chunk + 1) * 400), - ), + Arr.chunksOf(rows, 400), (chunk) => sql<{ readonly sequence: number; @@ -3153,7 +3073,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const sql = yield* SqlClient.SqlClient; const now = yield* DateTime.now; const threadId = ThreadId.make("thread:foundation-many-items"); @@ -3385,10 +3305,7 @@ it.effect.each(["events", "receipts"] as const)( const path = yield* Path.Path; const directory = yield* fs.makeTempDirectoryScoped({ prefix: "compaction-reader-" }); const database = SqlitePersistence.layerFromPath(path.join(directory, "statev2.sqlite")); - const maintenanceLayer = ProjectionMaintenance.layer.pipe( - Layer.provide(Layer.merge(EventStore.layer, ProjectionStore.layer)), - Layer.provideMerge(database), - ); + const maintenanceLayer = ProjectionMaintenance.layer.pipe(Layer.provideMerge(database)); yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; @@ -3435,7 +3352,7 @@ it.effect.each(["events", "receipts"] as const)( : Effect.succeed(statement); }; const compacted = yield* Deferred.make(); - const compaction = yield* maintenance.compactEventStore().pipe( + const compaction = yield* maintenance.pruneEventStore().pipe( Effect.provideService(Statement.CurrentTransformer, observeDiscovery), Effect.provideService(Statement.SpanPropagationEnabled, true), Effect.tap(() => Deferred.succeed(compacted, undefined)), diff --git a/apps/server/src/orchestration-v2/IdleCompaction.test.ts b/apps/server/src/orchestration-v2/IdleCompaction.test.ts index 3c13452e98e7f..a11a534b32614 100644 --- a/apps/server/src/orchestration-v2/IdleCompaction.test.ts +++ b/apps/server/src/orchestration-v2/IdleCompaction.test.ts @@ -163,6 +163,22 @@ describe("idleCompactionWindow", () => { const compacted = records(cache); compacted.messages = [{ ...compacted.messages[0]!, text: "/compact" }]; expect(idleCompactionWindow(compacted, NOW_MS)).toBeNull(); + const steered = records(cache); + steered.runs = [ + ...steered.runs, + { + ...steered.runs[0]!, + id: RunId.make("run-2"), + ordinal: 2, + userMessageId: MessageId.make("message-2"), + status: "cancelled", + startedAt: null, + }, + ]; + expect(idleCompactionWindow(steered, NOW_MS)?.runId).toBe(runId); + // A turn Claude itself reported as cancelled did run, so it stays the latest. + steered.runs = steered.runs.map((run) => ({ ...run, startedAt: at(-MINUTE_MS) })); + expect(idleCompactionWindow(steered, NOW_MS)).toBeNull(); const archived = records(cache); archived.thread = { ...archived.thread, archivedAt: at() }; expect(idleCompactionWindow(archived, NOW_MS)).toBeNull(); @@ -323,9 +339,17 @@ describe("IdleCompactionService", () => { ordinal: 2, userMessageId: laterMessageId, }; + const cancelledRun = { + ...earlierRun, + id: RunId.make("run-3"), + ordinal: 3, + userMessageId: MessageId.make("message-3"), + status: "cancelled" as const, + startedAt: null, + }; const data: OrchestrationV2ThreadProjection = { ...base, - runs: [earlierRun, laterRun], + runs: [earlierRun, laterRun, cancelledRun], messages: [ ...base.messages, { ...base.messages[0]!, id: laterMessageId, runId: laterRunId, text: "Keep going" }, diff --git a/apps/server/src/orchestration-v2/IdleCompaction.ts b/apps/server/src/orchestration-v2/IdleCompaction.ts index a89894045c298..c4d586817551f 100644 --- a/apps/server/src/orchestration-v2/IdleCompaction.ts +++ b/apps/server/src/orchestration-v2/IdleCompaction.ts @@ -51,10 +51,13 @@ export function idleCompactionWindow( if (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) return null; if (!isListedThreadLineage(thread.lineage)) return null; - const latestRun = runs.reduce<(typeof runs)[number] | null>( - (latest, run) => (latest === null || run.ordinal > latest.ordinal ? run : latest), - null, - ); + // A removed or steered queued message leaves a cancelled run that never started. + const latestRun = runs + .filter((run) => run.status !== "cancelled" || run.startedAt !== null) + .reduce<(typeof runs)[number] | null>( + (latest, run) => (latest === null || run.ordinal > latest.ordinal ? run : latest), + null, + ); if (latestRun?.status !== "completed" || latestRun.completedAt === null) return null; const message = messages.find((candidate) => candidate.id === latestRun.userMessageId); // Never compact a compaction, so one idle spell compacts at most once. diff --git a/apps/server/src/orchestration-v2/IdleCompactionService.ts b/apps/server/src/orchestration-v2/IdleCompactionService.ts index ce47b0f697700..c7035de4ec584 100644 --- a/apps/server/src/orchestration-v2/IdleCompactionService.ts +++ b/apps/server/src/orchestration-v2/IdleCompactionService.ts @@ -50,6 +50,8 @@ export const make = Effect.gen(function* () { ) ) return; + const activeProviderThreadIds = + thread.activeProviderThreadId === null ? [] : [thread.activeProviderThreadId]; const records = yield* projections.getThreadRecords( threadId, [ @@ -61,7 +63,20 @@ export const make = Effect.gen(function* () { "turnItems", "contextTransfers", ], - { messageRunIds: [runId], turnItemStatuses: ["pending", "running", "waiting"] }, + { + messageRunIds: [runId], + turnItemStatuses: ["pending", "running", "waiting"], + // The latest run, active runs that hold background work, and rolled-back runs whose + // items it ignores. + runStatuses: ["preparing", "starting", "running", "rolled_back"], + includeLatestRun: true, + // Without an active provider thread there is no session to compact. + providerThreadIds: activeProviderThreadIds, + providerTurnThreadIds: activeProviderThreadIds, + latestProviderTurnUsageOnly: true, + runtimeRequestStatuses: ["pending"], + contextTransferStatuses: ["pending"], + }, ); if ( resolveIdleCompaction(current, records.thread.providerInstanceId) !== @@ -73,10 +88,12 @@ export const make = Effect.gen(function* () { ) ) return; - const latestRun = records.runs.reduce<(typeof records.runs)[number] | undefined>( - (latest, run) => (latest === undefined || run.ordinal > latest.ordinal ? run : latest), - undefined, - ); + const latestRun = records.runs + .filter((run) => run.status !== "cancelled" || run.startedAt !== null) + .reduce<(typeof records.runs)[number] | undefined>( + (latest, run) => (latest === undefined || run.ordinal > latest.ordinal ? run : latest), + undefined, + ); // A late update for an earlier run replaced the latest run's timer, so take it over. if (latestRun?.status === "completed" && latestRun.id !== runId) { runId = latestRun.id; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 4618ccded245f..b1a0a79e2ef15 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -373,6 +373,9 @@ export interface OrchestratorV2Shape { fields: ReadonlyArray, filter?: ProjectionRecordFilter, ) => Effect.Effect, OrchestratorV2Error>; + readonly listAppOwnedChildThreadIds: ( + threadId: ThreadId, + ) => Effect.Effect, OrchestratorV2Error>; readonly getThreadProjection: ( threadId: ThreadId, ) => Effect.Effect; @@ -398,6 +401,18 @@ export interface OrchestratorV2Shape { }, OrchestratorV2Error >; + readonly getPullRequestSnapshot: ( + options: Parameters[0], + ) => Effect.Effect< + Effect.Success>, + OrchestratorV2Error + >; + readonly getAgentAwarenessSnapshot: ( + threadId?: ThreadId, + ) => Effect.Effect< + Effect.Success>, + OrchestratorV2Error + >; readonly getShellSnapshot: ( options?: ShellSnapshotOptions, ) => Effect.Effect; @@ -425,6 +440,10 @@ export interface OrchestratorV2Shape { readonly findGuestShareThreadId: ( shareId: GuestShareId, ) => Effect.Effect; + /** Titles of the given threads that exist, archived ones included. */ + readonly getThreadTitles: ( + threadIds: ReadonlyArray, + ) => Effect.Effect, OrchestratorV2Error>; readonly getThreadEventSequence: ( threadId: ThreadId, ) => Effect.Effect; @@ -645,7 +664,11 @@ function mayStartSubagentFollowup( if (command.scheduledTaskId !== undefined || command.restartContinuationOfRunId !== undefined) return false; if (command.notification !== undefined) return command.notification.source.kind !== "monitor"; - return command.createdBy === "user" || command.creationSource === "mcp"; + return ( + command.createdBy === "user" || + command.creationSource === "mcp" || + command.usageLimitContinuationOfRunId !== undefined + ); } /** Waiting runs only drain post-terminal output; their agent turn is over and still needs a wake. */ @@ -1112,6 +1135,35 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return withId; }); + // Detaching kills background work running inside the provider process. + // Probe even with an empty roster: Claude's native subagents never reach + // pendingBackgroundTasks. Sessions shared across threads probe per thread. + const sessionHasBackgroundWork = Effect.fn("Orchestrator.sessionHasBackgroundWork")(function* ( + detaching: OrchestrationV2ThreadProjection["providerSessions"][number], + providerThreads: OrchestrationV2ThreadProjection["providerThreads"], + ) { + const session = yield* providerSessions + .get(detaching.id) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(session)) return false; + const runtime = session.value; + const probes = + !detaching.capabilities.sessions.supportsMultipleProviderThreadsPerSession && + runtime.hasPendingBackgroundWork !== undefined + ? [runtime.hasPendingBackgroundWork] + : providerThreads.flatMap((providerThread) => + providerThread.providerSessionId === detaching.id && + runtime.hasPendingBackgroundWorkForThread !== undefined + ? [runtime.hasPendingBackgroundWorkForThread(providerThread)] + : [], + ); + for (const probe of probes) { + // A failed probe cannot confirm that the work ended. + if (yield* probe.pipe(Effect.catchCause(() => Effect.succeed(true)))) return true; + } + return false; + }); + // Runs in every command transaction; avoid reading thousands of finished leaf nodes. const readCommandProjection = (threadId: ThreadId) => projectionStore @@ -1300,27 +1352,40 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const offerDelegatedCompletionDelivery = /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ Effect.fnUntraced(function* (threadId: ThreadId, parentRunId: RunId) { - const projection = yield* projectionStore.getThreadRecords( - threadId, - ["runs", "messages", "providerTurns", "providerThreads"], - { messageRoles: ["user"] }, - ); - const parentRun = projection.runs.find((candidate) => candidate.id === parentRunId); + const controls = yield* projectionStore.getThreadRecords(threadId, ["runs"], { + runIds: [parentRunId], + }); + const parentRun = controls.runs[0]; const cohort = parentRun?.delegatedCompletion; const delivery = cohort?.delivery; if ( parentRun === undefined || cohort?.disposition !== "open" || - delivery === null || - delivery === undefined || + delivery == null || delivery.taskIds.length === 0 || - projection.thread.archivedAt !== null || - projection.thread.deletedAt !== null || - (completionDeliveryMessage(projection, delivery) !== undefined && - !isUndeliveredMailboxSteer(projection, delivery.messageId)) - ) { + controls.thread.archivedAt !== null || + controls.thread.deletedAt !== null + ) return; + const messages = yield* projectionStore.getThreadRecords(threadId, ["messages"], { + messageIds: [delivery.messageId], + }); + const message = messages.messages[0]; + if (message !== undefined) { + if (message.delegatedCompletion === undefined || message.runId === null) return; + const runs = yield* projectionStore.getThreadRecords(threadId, ["runs"], { + runIds: [message.runId], + }); + const run = runs.runs[0]; + const turns = yield* projectionStore.getThreadRecords(threadId, ["providerTurns"], { + providerTurnAttemptIds: run?.activeAttemptId == null ? [] : [run.activeAttemptId], + }); + if (!isUndeliveredMailboxSteer({ ...messages, ...runs, ...turns }, delivery.messageId)) + return; } + const projection = yield* projectionStore.getThreadRecords(threadId, ["providerThreads"], { + providerThreadIds: parentRun.providerThreadId === null ? [] : [parentRun.providerThreadId], + }); const providerThread = parentRun.providerThreadId === null ? undefined @@ -1755,6 +1820,42 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ) : null; + const releasableSessions = projection.providerSessions.filter( + (session) => + switchPlan?.releaseProviderSessionIds.includes(session.id) && + session.status !== "stopped" && + session.status !== "error", + ); + const busySessionIds = new Set(); + for (const session of releasableSessions) { + if (yield* sessionHasBackgroundWork(session, projection.providerThreads)) { + busySessionIds.add(session.id); + } + } + // Nobody waits on a queued start to refuse it. A handoff runs in its own session, so a + // busy one stays up; a restart resumes the busy session's native thread, so wait instead. + if (busySessionIds.size > 0 && switchPlan?.transition.type === "restart_and_resume") { + yield* Effect.logInfo( + "Holding the queue: the replaced provider session still runs background work", + { threadId, runId: queuedRun.id, providerSessionIds: Array.from(busySessionIds) }, + ); + yield* writeSystemEvents( + projection.runs + .filter((run) => run.status === "queued") + .map((run) => ({ + type: "run.updated" as const, + threadId, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, queueHeld: true }, + })), + ); + return; + } + const sessionsToDetach = releasableSessions.filter( + (session) => !busySessionIds.has(session.id), + ); const canResumeAcrossInstances = switchPlan?.instanceChanged === true && switchPlan.transition.type === "restart_and_resume" && @@ -2093,12 +2194,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ] : []; - const sessionsToDetach = projection.providerSessions.filter( - (session) => - switchPlan?.releaseProviderSessionIds.includes(session.id) && - session.status !== "stopped" && - session.status !== "error", - ); yield* writeSystemEvents( [ ...(selectionChanged @@ -2245,7 +2340,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, })), { - id: `effect:${commandId}:provider-turn.start:${queuedRun.id}`, + // A restart can return this run to the queue after its previous start effect was cancelled. + id: `effect:${commandId}:provider-turn.start:${queuedRun.id}:${yield* idAllocator.allocate.event({ threadId, commandId })}`, commandId, threadId, request: { type: "provider-turn.start", runId: queuedRun.id }, @@ -2558,6 +2654,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly disposition: "stopped" | "disposed"; readonly now: DateTime.Utc; readonly cancelQueuedDelivery?: boolean; + // An interrupted wake disposes only the results it carried. The cohort stays open, and its + // delivery stays the wake's, so the wake's terminal reserves any sibling's result. + readonly interruptedWakeTaskIds?: ReadonlyArray; }) { const parentRun = input.projection.runs.find((candidate) => candidate.id === input.parentRunId); if (parentRun === undefined) { @@ -2571,12 +2670,28 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return; } const emitEvent = emit(input.events, input.command); - const nextDisposition = cohort?.disposition === "disposed" ? "disposed" : input.disposition; - const nextCohort = { - disposition: nextDisposition, - nextGeneration: cohort?.nextGeneration ?? 1, - delivery: null, - } as const; + const wakeTaskIds = input.interruptedWakeTaskIds; + const keptTaskIds = new Set( + wakeTaskIds !== undefined && cohort?.disposition === "open" + ? tasks + .filter( + (task) => + !wakeTaskIds.includes(task.id) && + task.completionDelivery?.state !== "acknowledged" && + task.completionDelivery?.state !== "delivered" && + task.completionDelivery?.state !== "disposed", + ) + .map((task) => task.id) + : [], + ); + const nextCohort = + keptTaskIds.size > 0 + ? cohort! + : ({ + disposition: cohort?.disposition === "disposed" ? "disposed" : input.disposition, + nextGeneration: cohort?.nextGeneration ?? 1, + delivery: null, + } as const); yield* emitEvent({ type: "run.updated", threadId: parentRun.threadId, @@ -2591,6 +2706,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); for (const task of tasks) { if ( + keptTaskIds.has(task.id) || task.completionDelivery?.state === "acknowledged" || task.completionDelivery?.state === "delivered" || task.completionDelivery?.state === "disposed" @@ -2616,7 +2732,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } const deliveryRun = completionDeliveryRun(input.projection, cohort?.delivery ?? null); - if (input.cancelQueuedDelivery !== false && deliveryRun?.status === "queued") { + if ( + keptTaskIds.size === 0 && + input.cancelQueuedDelivery !== false && + deliveryRun?.status === "queued" + ) { yield* emitQueuedRunCancellation({ command: input.command, events: input.events, @@ -3193,16 +3313,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } } if (command.type === "thread.settle") { - const projection = yield* loadProjectionForCommand( - command, - ["runs", "runtimeRequests", "messages"], - { turnItemTypes: [], messageRoles: ["user"] }, - ); + const projection = yield* loadProjectionForCommand(command, ["runs", "runtimeRequests"]); + const queuedMessages = yield* projectionStore + .getMessagesById( + command.threadId, + projection.runs.filter((run) => run.status === "queued").map((run) => run.userMessageId), + ) + .pipe(mapDispatchError(command)); const automaticMessageIds = new Set( - projection.messages + [...queuedMessages.values()] .filter( (message) => - message.notification !== undefined || message.delegatedCompletion !== undefined, + message.role === "user" && + (message.notification !== undefined || message.delegatedCompletion !== undefined), ) .map((message) => message.id), ); @@ -3487,6 +3610,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio settledOverride: thread.settledOverride === "settled" ? "active" : thread.settledOverride, settledAt: thread.settledOverride === "settled" ? null : thread.settledAt, + ...(thread.settledOverride === "settled" ? { unsettledAt: now } : {}), snoozedUntil: null, snoozedAt: null, lastSnoozeWakeAt: thread.snoozedUntil == null ? thread.lastSnoozeWakeAt : now, @@ -4591,6 +4715,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly senderThreadId?: OrchestrationV2ConversationMessage["senderThreadId"]; readonly idleCompaction?: OrchestrationV2ConversationMessage["idleCompaction"]; readonly delegatedCompletion?: OrchestrationV2ConversationMessage["delegatedCompletion"]; + readonly notification?: OrchestrationV2ConversationMessage["notification"]; readonly forceRestart: boolean; }) { const targetRun = input.projection.runs.find((candidate) => candidate.id === input.targetRunId); @@ -4744,6 +4869,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(input.delegatedCompletion === undefined ? {} : { delegatedCompletion: input.delegatedCompletion }), + ...(input.notification === undefined ? {} : { notification: input.notification }), ...(input.scheduledTaskId === undefined ? {} : { scheduledTaskId: input.scheduledTaskId }), @@ -4827,13 +4953,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const selectionMustApplyNow = selectionChanged && (providerInstanceChanged || selectionTransition?.type !== "apply_on_next_turn"); + const attachmentsNeedRestart = + input.attachments.length > 0 && + session.providerSession.capabilities.turns.activeSteeringAcceptsAttachments === false; const steeringPolicy = yield* mapDispatchError(input.command)( commandPolicy.decideSteeringExecution({ commandId: input.command.commandId, threadId: input.command.threadId, providerInstanceId: targetRun.providerInstanceId, capabilities: session.providerSession.capabilities, - forceRestart: input.forceRestart || selectionMustApplyNow, + forceRestart: input.forceRestart || selectionMustApplyNow || attachmentsNeedRestart, }), ); @@ -4842,6 +4971,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio input.projection.thread.providerInstanceId !== input.modelSelection.instanceId; if ( input.delegatedCompletion === undefined && + input.notification === undefined && (instanceChanged || !modelSelectionsEqual(input.projection.thread.modelSelection, input.modelSelection)) ) { @@ -5632,13 +5762,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio taskIds: delivery.taskIds, }; } - // Route durable mailbox deliveries under the thread lock, using the live - // session's capabilities. Never interrupt/restart a turn for a notification. + // Route durable mailbox deliveries and delegated task questions under the + // thread lock, using the live session's capabilities. A question queued + // behind a long parent turn is never seen. Never interrupt/restart a turn + // for a notification. if ( - delegatedCompletion !== undefined && - delegatedCompletion.taskIds.every( - (id) => projection.subagents.find((task) => task.id === id)?.completionWake === "always", - ) + delegatedCompletion !== undefined + ? delegatedCompletion.taskIds.every( + (id) => + projection.subagents.find((task) => task.id === id)?.completionWake === "always", + ) + : command.notification?.source.kind === "delegated_task" ) { const active = projection.runs.find((run) => run.status === "running"); const providerThread = projection.providerThreads.find( @@ -5662,7 +5796,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( Option.isSome(session) && session.value.providerSession.capabilities.turns.supportsActiveSteering && - session.value.providerSession.capabilities.turns.activeSteeringInterruptsTools !== true + session.value.providerSession.capabilities.turns.activeSteeringInterruptsTools !== true && + !(yield* stopReachedRun(command, command.threadId, active.id)) ) { dispatchMode = { type: "steer_active", targetRunId: active.id }; } @@ -5729,11 +5864,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio effects, projection, modelSelection: - delegatedCompletion === undefined + delegatedCompletion === undefined && command.notification === undefined ? modelSelection : (projection.runs.find((run) => run.id === dispatchMode.targetRunId)?.modelSelection ?? modelSelection), delegatedCompletion, + ...(command.notification === undefined ? {} : { notification: command.notification }), targetRunId: dispatchMode.targetRunId, messageId: command.messageId, text: dispatchText, @@ -6144,24 +6280,43 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio activeProviderThread.providerInstanceId !== modelSelection.instanceId; // Account overlays share native history. Selection commands may already // have updated the app thread, so classify against the native thread's owner. + const providerSwitchPlan = isProviderSwitch + ? yield* providerSwitchService + .plan({ + projection: { + ...projection, + thread: { + ...projection.thread, + modelSelection: { + ...projection.thread.modelSelection, + instanceId: activeProviderThread.providerInstanceId, + }, + }, + }, + targetModelSelection: modelSelection, + }) + .pipe(mapDispatchError(command)) + : null; const canResumeAcrossInstances = isProviderSwitch && activeProviderThread.nativeThreadRef !== null && - (yield* providerSwitchService - .plan({ - projection: { - ...projection, - thread: { - ...projection.thread, - modelSelection: { - ...projection.thread.modelSelection, - instanceId: activeProviderThread.providerInstanceId, - }, - }, - }, - targetModelSelection: modelSelection, - }) - .pipe(mapDispatchError(command))).transition.type === "restart_and_resume"; + providerSwitchPlan?.transition.type === "restart_and_resume"; + const sessionsToDetach = projection.providerSessions.filter( + (session) => + providerSwitchPlan?.releaseProviderSessionIds.includes(session.id) && + session.status !== "stopped" && + session.status !== "error", + ); + for (const detaching of sessionsToDetach) { + if (yield* sessionHasBackgroundWork(detaching, projection.providerThreads)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: + "The previous provider is still running background agents or commands, and switching would end them. Wait for them to finish, or press Stop, then send the message again.", + }); + } + } if ( pendingForkTransfer === undefined && @@ -7123,7 +7278,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.context ? { context: command.context } : {}), attachments: command.attachments, ...(run.contextHandoffId === null - ? modelSwitchSince(projection, modelSelection, sourceRun?.modelSelection) + ? modelSwitchSince( + projection, + modelSelection, + // Side chats do not show the parent's runs. + sideChatFork ? undefined : sourceRun?.modelSelection, + ) : {}), }; const activeHandoff = portableForkHandoff ?? mergeBackHandoff ?? providerSwitchHandoff; @@ -7468,30 +7628,32 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } - if (canResumeAcrossInstances && activeProviderThread.providerSessionId !== null) { - const previousProviderSessionId = activeProviderThread.providerSessionId; + const detachDetail = canResumeAcrossInstances + ? "Provider account changed; continuing the native thread." + : "Provider or model selection changed."; + for (const session of sessionsToDetach) { yield* emitEvent({ type: "provider-session.detached", threadId: command.threadId, - driver: activeProviderThread.driver, - providerInstanceId: activeProviderThread.providerInstanceId, + driver: session.driver, + providerInstanceId: session.providerInstanceId, occurredAt: now, payload: { - providerSessionId: previousProviderSessionId, + providerSessionId: session.id, detachedAt: now, - reason: "Provider account changed; continuing the native thread.", + reason: detachDetail, }, }); yield* Ref.update(effects, (existing) => [ ...existing, { - id: `effect:${command.commandId}:provider-session.detach:${previousProviderSessionId}`, + id: `effect:${command.commandId}:provider-session.detach:${session.id}`, commandId: command.commandId, threadId: command.threadId, request: { type: "provider-session.detach", - providerSessionId: previousProviderSessionId, - detail: "Provider account changed; continuing the native thread.", + providerSessionId: session.id, + detail: detachDetail, }, } satisfies PendingOrchestrationEffectV2, ]); @@ -7805,6 +7967,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio parentThreadId === null || (command.notification === undefined && command.createdBy !== "user" && + command.usageLimitContinuationOfRunId === undefined && command.senderThreadId !== parentThreadId) ) return; @@ -8428,6 +8591,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio payload: completionPlan.message, }); } + return completionPlan.parentRun?.id; }); const dispatchCreatedThreadRecord = Effect.fn("orchestrationV2.dispatch.createdThreadRecord")( @@ -11171,6 +11335,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio parentRunId, disposition: "stopped", now, + ...(parentRunId !== run.id && completionMessage?.delegatedCompletion !== undefined + ? { interruptedWakeTaskIds: completionMessage.delegatedCompletion.taskIds } + : {}), }); } }); @@ -12630,7 +12797,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const projection = yield* projectionStore.getThreadRecords( threadId, ["runs", "messages", "subagents"], - { messageRoles: ["user"] }, + { + runIds: [runId, message.delegatedCompletion.parentRunId], + messageIds: [message.id], + subagentRunIds: [message.delegatedCompletion.parentRunId], + }, ); const deliveryRun = projection.runs.find((candidate) => candidate.id === runId); const deliveryMessage = @@ -13251,7 +13422,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* dispatchRemoteDelegatedTaskStop(command, events); break; case "delegated_task.wake-policy": - yield* dispatchDelegatedTaskWakePolicy(command, events); + updateDelegatedCompletionOnlyRunId = yield* dispatchDelegatedTaskWakePolicy( + command, + events, + ); break; case "delegated_task.completion-delivery.acknowledge": case "delegated_task.completion-delivery.dispose": @@ -13925,6 +14099,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); }, ), + listAppOwnedChildThreadIds: Effect.fn("OrchestratorV2.listAppOwnedChildThreadIds")( + function (threadId) { + return projectionStore + .listAppOwnedChildThreadIds(threadId) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + }, + ), getThreadProjection: Effect.fn("OrchestratorV2.getThreadProjection")(function (threadId) { return projectionStore .getThreadProjection(threadId) @@ -13947,6 +14128,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); }, ), + getPullRequestSnapshot: Effect.fn("OrchestratorV2.getPullRequestSnapshot")(function (options) { + return projectionStore + .getPullRequestSnapshot(options) + .pipe(Effect.mapError(shellProjectionError)); + }), + getAgentAwarenessSnapshot: Effect.fn("OrchestratorV2.getAgentAwarenessSnapshot")( + function (threadId) { + return projectionStore + .getAgentAwarenessSnapshot(threadId) + .pipe(Effect.mapError(shellProjectionError)); + }, + ), getShellSnapshot: Effect.fn("OrchestratorV2.getShellSnapshot")(function (options) { return projectionStore.getShellSnapshot(options).pipe(Effect.mapError(shellProjectionError)); }), @@ -13986,6 +14179,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .findGuestShareThreadId(shareId) .pipe(Effect.mapError(shellProjectionError)); }), + getThreadTitles: Effect.fn("OrchestratorV2.getThreadTitles")(function (threadIds) { + return projectionStore.getThreadTitles(threadIds).pipe(Effect.mapError(shellProjectionError)); + }), getThreadEventSequence: Effect.fn("OrchestratorV2.getThreadEventSequence")(function (threadId) { return eventSink .latestSequence({ threadId }) diff --git a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts index 9772a559f32c1..b27b6604b1435 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 NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { assert, it } from "@effect/vitest"; @@ -116,7 +117,7 @@ const unusedEnrichment = { const layerRuntime = (dbPath: string) => { const layerPlatform = Layer.merge( - NodeServices.layer, + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ resolveLink: () => Effect.die("unused"), }), @@ -157,8 +158,7 @@ const layerRuntime = (dbPath: string) => { Layer.provide(ServerSettings.layerTest()), Layer.provide( Layer.succeed(ProviderInstanceRegistry.ProviderInstanceRegistry, { - // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. - getInstance: () => Effect.succeed(undefined), + getInstance: () => Effect.undefined, listInstances: Effect.succeed([]), listUnavailable: Effect.succeed([]), streamChanges: Stream.empty, @@ -190,5 +190,5 @@ it.live("keeps project settings through the V2 migrations and the first V2 boot" assert.equal(project._tag, "Some"); }).pipe(Effect.provide(layerRuntime(dbPath))); }), - ).pipe(Effect.provide(NodeServices.layer)), + ).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.test.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.test.ts new file mode 100644 index 0000000000000..1f42953cc4ec3 --- /dev/null +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.test.ts @@ -0,0 +1,385 @@ +import { assert, it } from "@effect/vitest"; +import { + EventId, + NodeId, + PlanId, + type OrchestrationV2AppThread, + type OrchestrationV2DomainEvent, + type OrchestrationV2TurnItem, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnItemId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as Statement from "effect/sql/Statement"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as EventSink from "./EventSink.ts"; +import * as EventStore from "./EventStore.ts"; +import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; + +const database = SqlitePersistence.layerMemory; +const eventStoreProvided = EventStore.layer.pipe(Layer.provideMerge(database)); +const projectionStoreProvided = ProjectionStore.layer.pipe(Layer.provideMerge(database)); +const stores = Layer.mergeAll(database, eventStoreProvided, projectionStoreProvided); +const layerTest = Layer.mergeAll( + stores, + OrchestrationEventStore.layer.pipe(Layer.provide(database)), + EventSink.layer.pipe(Layer.provide(stores)), + ProjectionMaintenance.layer.pipe(Layer.provide(stores)), +); + +const providerInstanceId = ProviderInstanceId.make("codex"); +const providerDriver = ProviderDriverKind.make("codex"); +const modelSelection = { instanceId: providerInstanceId, model: "gpt-5.4" }; + +function makeThread(threadId: ThreadId, now: DateTime.Utc): OrchestrationV2AppThread { + return { + id: threadId, + projectId: ProjectId.make(`project:${threadId}`), + title: "Turn item compaction", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { rootThreadId: threadId, parentThreadId: null, relationshipToParent: null }, + forkedFrom: null, + createdBy: "user", + creationSource: "web", + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; +} + +function threadCreated(thread: OrchestrationV2AppThread, now: DateTime.Utc) { + return { + id: EventId.make("event:turn-item-compaction:thread"), + type: "thread.created", + threadId: thread.id, + providerInstanceId, + occurredAt: now, + payload: thread, + } satisfies OrchestrationV2DomainEvent; +} + +const itemSpecs = [ + { key: "command", type: "command_execution", ordinal: 10 }, + { key: "reasoning", type: "reasoning", ordinal: 20 }, + { key: "todo", type: "todo_list", ordinal: 30 }, + { key: "commanded", type: "command_execution", ordinal: 40 }, +] as const; + +function itemEvent( + threadId: ThreadId, + now: DateTime.Utc, + spec: (typeof itemSpecs)[number], + revision: number, + eventId = `event:turn-item-compaction:${spec.key}:${revision}`, +): OrchestrationV2DomainEvent { + const id = TurnItemId.make(`turn-item:compaction:${spec.key}`); + const growing = `${spec.key}:${revision}:` + "x".repeat((revision + 1) * 192); + const completed = revision === 69; + const base = { + id, + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: spec.ordinal, + status: completed ? ("completed" as const) : ("running" as const), + title: spec.key, + startedAt: now, + completedAt: completed ? now : null, + updatedAt: now, + }; + let payload: OrchestrationV2TurnItem; + switch (spec.type) { + case "command_execution": + payload = { + ...base, + type: "command_execution", + input: `run ${spec.key}`, + output: growing, + }; + break; + case "reasoning": + payload = { ...base, type: "reasoning", text: growing, streaming: !completed }; + break; + case "todo_list": + payload = { + ...base, + type: "todo_list", + planId: PlanId.make("plan:turn-item-compaction"), + steps: [{ id: "step", text: growing, status: completed ? "completed" : "running" }], + explanation: growing, + }; + break; + } + return { + id: EventId.make(eventId), + type: "turn-item.updated", + threadId, + occurredAt: now, + payload, + }; +} + +function subagentEvents( + threadId: ThreadId, + now: DateTime.Utc, + revision: number, + eventSuffix = String(revision), +): ReadonlyArray { + const id = NodeId.make("subagent:turn-item-compaction"); + const prompt = revision === 0 ? "Review prompt A. ".repeat(40) : "Review prompt B. ".repeat(40); + const progress = `Review progress ${revision}`; + return [ + { + id: EventId.make(`event:turn-item-compaction:subagent:${eventSuffix}`), + type: "subagent.updated", + threadId, + occurredAt: now, + payload: { + id, + threadId, + runId: null, + parentNodeId: NodeId.make("node:turn-item-compaction-parent"), + origin: "provider_native", + createdBy: "agent", + driver: providerDriver, + providerInstanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: { + driver: providerDriver, + nativeId: "compaction-review", + strength: "strong", + }, + prompt, + progress, + title: "Review", + model: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }, + { + id: EventId.make(`event:turn-item-compaction:subagent-item:${eventSuffix}`), + type: "turn-item.updated", + threadId, + occurredAt: now, + payload: { + id: TurnItemId.make("turn-item:compaction:subagent"), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 50, + status: "running", + title: "Review", + startedAt: now, + completedAt: null, + updatedAt: now, + type: "subagent", + subagentId: id, + origin: "provider_native", + driver: providerDriver, + providerInstanceId, + childThreadId: null, + prompt, + progress, + result: null, + }, + }, + ] satisfies ReadonlyArray; +} + +const insertRunningRunUpdate = (threadId: ThreadId) => + Effect.flatMap( + SqlClient.SqlClient, + (sql) => sql<{ readonly sequence: number }>` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, + occurred_at, actor_kind, payload_json, metadata_json, application_event_version + ) VALUES ( + 'event:prune:run-running', 'thread', ${threadId}, + (SELECT MAX(stream_version) + 1 FROM orchestration_events WHERE stream_id = ${threadId}), + 'run.updated', '2026-11-01T00:00:00.000Z', 'server', '{"status":"running"}', '{}', 2 + ) RETURNING sequence + `, + ); + +const remainingEventIds = (threadId: ThreadId) => + Effect.flatMap( + SqlClient.SqlClient, + (sql) => sql<{ readonly event_id: string }>` + SELECT event_id FROM orchestration_events WHERE stream_id = ${threadId} ORDER BY sequence + `, + ).pipe(Effect.map((rows) => rows.map((row) => row.event_id))); + +it.effect("prunes events the projections cover once the retained day has passed", () => + Effect.gen(function* () { + const sink = yield* EventSink.EventSinkV2; + const eventStore = yield* EventStore.EventStoreV2; + const events = yield* OrchestrationEventStore.OrchestrationEventStore; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:prune"); + yield* sink.write({ events: [threadCreated(makeThread(threadId, now), now)] }); + for (const revision of [0, 1, 2]) { + yield* sink.write({ events: [itemEvent(threadId, now, itemSpecs[0], revision)] }); + } + yield* sink.write({ events: subagentEvents(threadId, now, 0) }); + yield* insertRunningRunUpdate(threadId); + yield* sink.write({ events: [itemEvent(threadId, now, itemSpecs[1], 0)] }); + + // The first pass only marks the head; nothing is a day old yet. + assert.equal((yield* maintenance.pruneEventStore()).deletedEventCount, 0); + const markedThrough = yield* eventStore.latestSequence(); + + // Repeating prompt A borrows it from the anchors written before the mark. + yield* sink.write({ events: subagentEvents(threadId, now, 0, "repeat") }); + yield* sink.write({ events: [itemEvent(threadId, now, itemSpecs[0], 3)] }); + const highWater = yield* eventStore.latestSequence(); + const projection = yield* projections.getThreadProjection(threadId); + + yield* TestClock.adjust("23 hours"); + assert.equal((yield* maintenance.pruneEventStore()).deletedEventCount, 0); + yield* TestClock.adjust("1 hour"); + assert.equal((yield* maintenance.pruneEventStore()).deletedEventCount, 4); + + assert.deepEqual(yield* remainingEventIds(threadId), [ + "event:turn-item-compaction:thread", + "event:turn-item-compaction:subagent:0", + "event:turn-item-compaction:subagent-item:0", + "event:prune:run-running", + "event:turn-item-compaction:subagent:repeat", + "event:turn-item-compaction:subagent-item:repeat", + "event:turn-item-compaction:command:3", + ]); + assert.equal(yield* events.prunedThroughSequence, markedThrough); + assert.equal(yield* eventStore.latestSequence(), highWater); + assert.equal(yield* eventStore.latestSequence({ threadId }), highWater); + assert.deepEqual(yield* projections.getThreadProjection(threadId), projection); + const repeated = yield* eventStore + .read({ threadId, eventType: "subagent.updated", afterSequence: markedThrough }) + .pipe(Stream.runCollect); + assert.deepEqual( + Array.from(repeated, (stored) => + stored.event.type === "subagent.updated" ? stored.event.payload.prompt : null, + ), + ["Review prompt A. ".repeat(40)], + ); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("keeps the anchor a retained stream head borrows from", () => + Effect.gen(function* () { + const sink = yield* EventSink.EventSinkV2; + const eventStore = yield* EventStore.EventStoreV2; + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:prune-head"); + yield* sink.write({ events: [threadCreated(makeThread(threadId, now), now)] }); + yield* sink.write({ events: subagentEvents(threadId, now, 0) }); + // The stream's newest row borrows prompt A, so it is kept below the boundary. + yield* sink.write({ events: subagentEvents(threadId, now, 0, "repeat") }); + yield* maintenance.pruneEventStore(); + yield* TestClock.adjust("1 day"); + assert.isAbove((yield* maintenance.pruneEventStore()).deletedEventCount, 0); + + assert.include( + yield* remainingEventIds(threadId), + "event:turn-item-compaction:subagent-item:0", + ); + const stored = yield* eventStore.read({ threadId }).pipe(Stream.runCollect); + const head = Array.from(stored).at(-1)?.event; + assert.deepEqual( + head?.type === "turn-item.updated" && head.payload.type === "subagent" + ? head.payload.prompt + : null, + "Review prompt A. ".repeat(40), + ); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("keeps an anchor that a row appended during the pass borrows from", () => + Effect.scoped( + Effect.gen(function* () { + const sink = yield* EventSink.EventSinkV2; + const eventStore = yield* EventStore.EventStoreV2; + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:prune-race"); + yield* sink.write({ events: [threadCreated(makeThread(threadId, now), now)] }); + yield* sink.write({ events: subagentEvents(threadId, now, 0) }); + yield* maintenance.pruneEventStore(); + yield* sink.write({ events: [itemEvent(threadId, now, itemSpecs[0], 0)] }); + yield* TestClock.adjust("1 day"); + + const scanStarted = yield* Deferred.make(); + const releaseScan = yield* Deferred.make(); + let blocked = false; + const blockFirstScanPage: Statement.Transformer = (statement) => { + const [query] = statement.compile(); + if (!blocked && query.includes("END AS prunable")) { + blocked = true; + return Deferred.succeed(scanStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseScan)), + Effect.as(statement), + ); + } + return Effect.succeed(statement); + }; + const pass = yield* maintenance + .pruneEventStore() + .pipe( + Effect.provideService(Statement.CurrentTransformer, blockFirstScanPage), + Effect.forkScoped, + ); + yield* Deferred.await(scanStarted); + yield* sink.write({ events: subagentEvents(threadId, now, 0, "late") }); + yield* Deferred.succeed(releaseScan, undefined); + assert.equal((yield* Fiber.join(pass)).deletedEventCount, 0); + + const late = yield* eventStore + .read({ threadId, eventType: "subagent.updated" }) + .pipe(Stream.runCollect); + assert.deepEqual( + Array.from(late, (stored) => + stored.event.type === "subagent.updated" ? stored.event.payload.prompt : null, + ), + ["Review prompt A. ".repeat(40), "Review prompt A. ".repeat(40)], + ); + }), + ).pipe(Effect.provide(layerTest)), +); diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index 211fb2ad8e7a3..210322aed686f 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -1,26 +1,18 @@ -import { ThreadId } from "@t3tools/contracts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import * as SqlClient from "effect/sql/SqlClient"; -import * as EventStore from "./EventStore.ts"; +import { + EVENT_LOG_PRUNE_MARK, + EVENT_LOG_PRUNED_THROUGH, +} from "../persistence/OrchestrationEventStore.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -export interface ProjectionVerificationV2 { - readonly valid: boolean; - readonly schemaVersion: number; - readonly expectedSequence: number; - readonly projectionSequence: number; - readonly unreadableThreadIds: ReadonlyArray; - readonly missingThreadIds: ReadonlyArray; - readonly unexpectedThreadIds: ReadonlyArray; -} - export class ProjectionMaintenanceError extends Schema.TaggedError()( "ProjectionMaintenanceError", { @@ -30,9 +22,8 @@ export class ProjectionMaintenanceError extends Schema.TaggedError; - readonly rebuild: Effect.Effect; - readonly compactEventStore: (betweenPages?: Effect.Effect) => Effect.Effect< + /** Deletes events the projections already cover once they are older than the retained history. */ + readonly pruneEventStore: (betweenPages?: Effect.Effect) => Effect.Effect< { readonly deletedEventCount: number; readonly deletedReceiptCount: number; @@ -47,369 +38,282 @@ export class ProjectionMaintenanceV2 extends Context.Service< ProjectionMaintenanceV2Shape >()("t3/orchestration-v2/ProjectionMaintenance/ProjectionMaintenanceV2") {} -type ProjectionMetadataRow = { - readonly schema_version: number; +// Reconnect replay and command-id retries read this tail; older cursors resync from a snapshot. +const RETAINED_EVENT_HISTORY = Duration.days(1); +// Small pages bound work on the reader worker and synchronous fallback. +const PAGE_SIZE = 50; +const REFERENCE_SCAN_WINDOW = 1000; + +type MetadataRow = { + readonly projection_name: string; readonly last_sequence: number; + readonly updated_at: string; }; -const encodeEntityKey = Schema.encodeSync( - Schema.fromJsonString(Schema.Tuple([Schema.String, Schema.String, Schema.NullOr(Schema.String)])), -); - -export const layer: Layer.Layer< - ProjectionMaintenanceV2, - never, - EventStore.EventStoreV2 | ProjectionStore.ProjectionStoreV2 | SqlClient.SqlClient -> = Layer.effect( +export const layer: Layer.Layer = Layer.effect( ProjectionMaintenanceV2, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const eventStore = yield* EventStore.EventStoreV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - - const verify = Effect.gen(function* () { - const expectedThreadRows = yield* sql<{ readonly thread_id: string }>` - SELECT DISTINCT stream_id AS thread_id - FROM orchestration_events INDEXED BY orchestration_events_v2_created_threads_idx - WHERE application_event_version = 2 - AND aggregate_kind = 'thread' - AND event_type = 'thread.created' - ORDER BY stream_id ASC - `; - const projectionRows = yield* sql<{ readonly thread_id: string }>` - SELECT thread_id - FROM orchestration_v2_projection_threads - ORDER BY thread_id ASC - `; - const actualIds = projectionRows.map((row) => ThreadId.make(row.thread_id)); - const expectedIds = expectedThreadRows.map((row) => ThreadId.make(row.thread_id)); - const actualSet = new Set(actualIds); - const expectedSet = new Set(expectedIds); - const missingThreadIds = expectedIds.filter((threadId) => !actualSet.has(threadId)); - const unexpectedThreadIds = actualIds.filter((threadId) => !expectedSet.has(threadId)); - const unreadableThreadIds = yield* projectionStore.getUnreadableThreadIds(); - const metadata = yield* sql` - SELECT schema_version, last_sequence - FROM orchestration_v2_projection_metadata - WHERE projection_name = 'thread-projections' - LIMIT 1 - `; - const expectedSequence = yield* eventStore.latestSequence(); - const schemaVersion = metadata[0]?.schema_version ?? 0; - const projectionSequence = metadata[0]?.last_sequence ?? 0; - return { - valid: - schemaVersion === ProjectionStore.ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION && - projectionSequence === expectedSequence && - missingThreadIds.length === 0 && - unexpectedThreadIds.length === 0 && - unreadableThreadIds.length === 0, - schemaVersion, - expectedSequence, - projectionSequence, - unreadableThreadIds, - missingThreadIds, - unexpectedThreadIds, - } satisfies ProjectionVerificationV2; - }).pipe(sql.withTransaction); - const rebuild = Effect.gen(function* () { - yield* sql.withTransaction( - Effect.gen(function* () { - const throughSequence = yield* eventStore.latestSequence(); - yield* sql`DELETE FROM orchestration_v2_projection_context_transfers`; - yield* sql`DELETE FROM orchestration_v2_projection_context_handoffs`; - yield* sql`DELETE FROM orchestration_v2_projection_checkpoints`; - yield* sql`DELETE FROM orchestration_v2_projection_checkpoint_scopes`; - yield* sql`DELETE FROM orchestration_v2_projection_turn_items`; - yield* sql`DELETE FROM orchestration_v2_projection_plans`; - yield* sql`DELETE FROM orchestration_v2_projection_messages`; - yield* sql`DELETE FROM orchestration_v2_projection_runtime_requests`; - yield* sql`DELETE FROM orchestration_v2_projection_provider_turns`; - yield* sql`DELETE FROM orchestration_v2_projection_provider_threads`; - yield* sql`DELETE FROM orchestration_v2_projection_provider_session_bindings`; - yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions`; - yield* sql`DELETE FROM orchestration_v2_projection_subagents`; - yield* sql`DELETE FROM orchestration_v2_projection_nodes`; - yield* sql`DELETE FROM orchestration_v2_projection_run_attempts`; - yield* sql`DELETE FROM orchestration_v2_projection_runs`; - yield* sql`DELETE FROM orchestration_v2_projection_threads`; - yield* sql`DELETE FROM orchestration_v2_turn_item_positions`; + const writeMetadata = (name: string, sequence: number, at: DateTime.Utc) => sql` + INSERT INTO orchestration_v2_projection_metadata ( + projection_name, schema_version, last_sequence, updated_at + ) + VALUES ( + ${name}, + ${ProjectionStore.ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION}, + ${sequence}, + ${DateTime.formatIso(at)} + ) + ON CONFLICT(projection_name) DO UPDATE SET + last_sequence = excluded.last_sequence, + updated_at = excluded.updated_at + `; - const pageSize = 500; - let lastSequence = 0; - while (true) { - const page = yield* eventStore - .read({ afterSequence: lastSequence, throughSequence, limit: pageSize }) - .pipe(Stream.runCollect); - for (const stored of page) { - yield* projectionStore.apply(stored.event); - if (stored.event.type === "turn-item.updated") { - yield* sql` - INSERT INTO orchestration_v2_turn_item_positions ( - thread_id, - turn_item_id, - ordinal - ) - VALUES ( - ${stored.event.threadId}, - ${stored.event.payload.id}, - ${stored.event.payload.ordinal} - ) - ON CONFLICT(thread_id, turn_item_id) DO UPDATE SET - ordinal = excluded.ordinal - `; - } - lastSequence = stored.sequence; - } - if (page.length < pageSize) break; - yield* Effect.yieldNow; - } - const now = DateTime.formatIso(yield* DateTime.now); - yield* sql` - INSERT INTO orchestration_v2_projection_metadata ( - projection_name, - schema_version, - last_sequence, - updated_at - ) - VALUES ( - 'thread-projections', - ${ProjectionStore.ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION}, - ${lastSequence}, - ${now} - ) - ON CONFLICT(projection_name) DO UPDATE SET - schema_version = excluded.schema_version, - last_sequence = excluded.last_sequence, - updated_at = excluded.updated_at - `; - }), - ); - return yield* verify; + // Collects anchors whose prompt later rows borrow through promptSequence. + const readPromptReferences = Effect.fnUntraced(function* ( + afterSequence: number, + throughSequence: number, + betweenPages: Effect.Effect, + ) { + const referenced = new Set(); + for (let cursor = afterSequence; cursor < throughSequence;) { + const windowEnd = Math.min(cursor + REFERENCE_SCAN_WINDOW, throughSequence); + const rows = yield* sql<{ readonly prompt_sequence: number }>` + SELECT json_extract(payload_json, '$.promptSequence') AS prompt_sequence + FROM orchestration_events + WHERE sequence > ${cursor} + AND sequence <= ${windowEnd} + AND event_type IN ('subagent.updated', 'turn-item.updated') + AND json_type(payload_json, '$.promptSequence') = 'integer' + `.pipe(NodeSqliteClient.readOnly); + for (const row of rows) referenced.add(row.prompt_sequence); + cursor = windowEnd; + yield* betweenPages; + } + return referenced; }); - const mapError = - (operation: string) => - (effect: Effect.Effect) => - effect.pipe( - Effect.mapError((cause) => new ProjectionMaintenanceError({ operation, cause })), - ); - - // Keep thread.created as the verification and replay anchor; clients recognize deletion by thread.deleted event type. - const SUPERSEDABLE_THREAD_EVENT_TYPES = [ - "thread.archived", - "thread.unarchived", - "thread.settled", - "thread.unsettled", - "thread.snoozed", - "thread.unsnoozed", - "thread.pinned", - "thread.auto-settle-set", - "thread.unpinned", - "thread.pin-reordered", - "thread.active-reordered", - "thread.metadata-updated", - "thread.pull-request-synced", - "thread.runtime-mode-updated", - "thread.interaction-mode-updated", - "thread.model-selection-updated", - "thread.provider-switched", - "thread.guest-share-updated", - ]; - // Read-state events preserve updatedAt and supersede only each other. Activity events supersede both groups. - const READ_STATE_THREAD_EVENT_TYPES = ["thread.visited", "thread.marked-unread"]; - // Older clients skip tag events, so these supersede only each other. - const TAG_THREAD_EVENT_TYPE = "thread.tags-updated"; - - const supersedableThreadEventTypes = new Set(SUPERSEDABLE_THREAD_EVENT_TYPES); - const readStateThreadEventTypes = new Set(READ_STATE_THREAD_EVENT_TYPES); - // Subagent snapshots stay intact because later rows can reference an earlier prompt. - // Commanded turn items stay for command-result replay. - // Small pages bound work on the reader worker and synchronous fallback. - const COMPACTION_PAGE_SIZE = 50; - - // Keep first/latest keyed events for replay order; page every event to bound sparse scans. - const compactEventStore = Effect.fn("ProjectionMaintenance.compactEventStore")(function* ( - betweenPages: Effect.Effect = Effect.void, + const pruneEvents = Effect.fnUntraced(function* ( + throughSequence: number, + head: number, + betweenPages: Effect.Effect, ) { - const bounds = yield* sql<{ - readonly event_sequence: number; - readonly receipt_row_id: number; - }>` - SELECT - COALESCE((SELECT MAX(sequence) FROM orchestration_events), 0) AS event_sequence, - COALESCE((SELECT MAX(rowid) FROM orchestration_command_receipts), 0) AS receipt_row_id + const referenced = yield* readPromptReferences(throughSequence, head, betweenPages); + // Stream heads at or below the boundary are kept, so the anchors they borrow must stay too. + const keptHeadReferences = yield* sql<{ readonly prompt_sequence: number }>` + SELECT json_extract(event.payload_json, '$.promptSequence') AS prompt_sequence + FROM ( + SELECT MAX(sequence) AS sequence FROM orchestration_events + GROUP BY aggregate_kind, stream_id + ) AS heads + JOIN orchestration_events AS event ON event.sequence = heads.sequence + WHERE heads.sequence <= ${throughSequence} + AND event.event_type IN ('subagent.updated', 'turn-item.updated') + AND json_type(event.payload_json, '$.promptSequence') = 'integer' `.pipe(NodeSqliteClient.readOnly); - let throughSequence = bounds[0]?.event_sequence ?? 0; - let throughReceiptRowId = bounds[0]?.receipt_row_id ?? 0; - const retainedThreadIds = new Set(); - const retainedReadStateThreadIds = new Set(); - const retainedTagThreadIds = new Set(); - // Retain each key's oldest candidate, deleting it only when an older event appears. - const oldestCandidateByEntityKey = new Map(); - let deletedEventCount = 0; - let deletedReceiptCount = 0; - - while (throughSequence > 0) { - const rows = yield* sql<{ - readonly sequence: number; - readonly application_event_version: number; - readonly aggregate_kind: string; - readonly stream_id: string; - readonly event_type: string; - readonly entity_id: string | null; - readonly imported_legacy_thread: number; - }>` + for (const row of keptHeadReferences) referenced.add(row.prompt_sequence); + yield* betweenPages; + let referencesThrough = head; + let deleted = 0; + let cursor = 0; + while (cursor < throughSequence) { + const rows = yield* sql<{ readonly sequence: number; readonly prunable: number }>` SELECT event.sequence, - event.application_event_version, - event.aggregate_kind, - event.stream_id, - event.event_type, - CASE - WHEN event.application_event_version = 2 - AND event.event_type IN ('message.updated', 'node.updated') - THEN json_extract(event.payload_json, '$.id') - WHEN event.application_event_version = 2 - AND event.event_type = 'turn-item.updated' - AND event.command_id IS NULL - AND json_extract(event.payload_json, '$.type') <> 'subagent' - THEN json_extract(event.payload_json, '$.id') - WHEN event.application_event_version = 2 - AND event.event_type = 'thread.pull-request-link-updated' - THEN json_array(json_extract(event.payload_json, '$.host'), - json_extract(event.payload_json, '$.repository'), - json_extract(event.payload_json, '$.number')) - ELSE NULL - END AS entity_id, CASE - WHEN event.application_event_version = 1 AND event.aggregate_kind = 'thread' - THEN EXISTS ( - SELECT 1 FROM orchestration_v2_legacy_imports AS legacy_import - WHERE legacy_import.thread_id = event.stream_id - AND legacy_import.transcript_imported_at IS NOT NULL - ) - ELSE 0 - END AS imported_legacy_thread + WHEN event.application_event_version = 1 THEN 0 + -- The legacy importer treats a thread without thread.created as not yet imported. + WHEN event.event_type = 'thread.created' THEN 0 + -- Codex usage history reads the selection a run attempt started with. + WHEN event.event_type = 'run.updated' + AND json_extract(event.payload_json, '$.status') = 'running' THEN 0 + -- The newest row keeps each stream's high water, stream_version, and snapshot sequence. + WHEN event.sequence = ( + SELECT MAX(newer.sequence) FROM orchestration_events AS newer + WHERE newer.aggregate_kind = event.aggregate_kind + AND newer.stream_id = event.stream_id + ) THEN 0 + ELSE 1 + END AS prunable FROM orchestration_events AS event - WHERE event.sequence <= ${throughSequence} - ORDER BY event.sequence DESC - LIMIT ${COMPACTION_PAGE_SIZE + 1} + WHERE event.sequence > ${cursor} + AND event.sequence <= ${throughSequence} + ORDER BY event.sequence ASC + LIMIT ${PAGE_SIZE} `.pipe(NodeSqliteClient.readOnly); - const page = rows.slice(0, COMPACTION_PAGE_SIZE); - const obsolete: number[] = []; - for (const row of page) { - if (row.imported_legacy_thread === 1) { - obsolete.push(row.sequence); - } else if (row.application_event_version === 2) { - if ( - row.aggregate_kind === "thread" && - supersedableThreadEventTypes.has(row.event_type) - ) { - if (retainedThreadIds.has(row.stream_id)) obsolete.push(row.sequence); - else retainedThreadIds.add(row.stream_id); - } else if ( - row.aggregate_kind === "thread" && - readStateThreadEventTypes.has(row.event_type) - ) { - if ( - retainedThreadIds.has(row.stream_id) || - retainedReadStateThreadIds.has(row.stream_id) - ) { - obsolete.push(row.sequence); - } else retainedReadStateThreadIds.add(row.stream_id); - } else if ( - row.aggregate_kind === "thread" && - row.event_type === TAG_THREAD_EVENT_TYPE - ) { - if (retainedThreadIds.has(row.stream_id) || retainedTagThreadIds.has(row.stream_id)) { - obsolete.push(row.sequence); - } else retainedTagThreadIds.add(row.stream_id); - } else if ( - row.event_type === "thread.pull-request-link-updated" && - retainedThreadIds.has(row.stream_id) - ) { - // A newer whole-thread payload already carries this link's state. - obsolete.push(row.sequence); - } else if ( - row.event_type === "thread.pull-request-link-updated" || - row.event_type === "message.updated" || - row.event_type === "node.updated" || - (row.event_type === "turn-item.updated" && row.entity_id !== null) - ) { - const key = encodeEntityKey([row.event_type, row.stream_id, row.entity_id]); - const candidate = oldestCandidateByEntityKey.get(key); - if (candidate === undefined) oldestCandidateByEntityKey.set(key, null); - else { - if (candidate !== null) obsolete.push(candidate); - oldestCandidateByEntityKey.set(key, row.sequence); + const candidates = rows.filter((row) => row.prunable === 1).map((row) => row.sequence); + // Never hold a transaction or the reader lock across a yield or a rest. + yield* Effect.yieldNow; + if (candidates.length > 0) { + deleted += yield* sql.withTransaction( + Effect.gen(function* () { + // Rows appended since the reference scan may borrow a prompt from a candidate. + const appended = yield* sql<{ + readonly sequence: number; + readonly prompt_sequence: number | null; + }>` + SELECT + sequence, + CASE + WHEN event_type IN ('subagent.updated', 'turn-item.updated') + AND json_type(payload_json, '$.promptSequence') = 'integer' + THEN json_extract(payload_json, '$.promptSequence') + END AS prompt_sequence + FROM orchestration_events + WHERE sequence > ${referencesThrough} + `; + for (const row of appended) { + if (row.prompt_sequence !== null) referenced.add(row.prompt_sequence); + referencesThrough = Math.max(referencesThrough, row.sequence); } - } - } + const obsolete = candidates.filter((sequence) => !referenced.has(sequence)); + if (obsolete.length === 0) return 0; + yield* sql`DELETE FROM orchestration_events WHERE sequence IN ${sql.in(obsolete)}`; + return obsolete.length; + }), + ); + yield* Effect.yieldNow; } - // Never hold a transaction or the reader lock across a yield or a rest. + // Rest after every page, including the last, so the next loop starts a fresh slice. + yield* betweenPages; + if (rows.length < PAGE_SIZE) break; + cursor = rows.at(-1)?.sequence ?? throughSequence; + } + return deleted; + }); + + // Imported legacy threads are served from v2 rows; their v1 events and receipts have no reader. + const pruneImportedLegacyHistory = Effect.fnUntraced(function* ( + throughSequence: number, + throughReceiptRowId: number, + betweenPages: Effect.Effect, + ) { + let deletedEventCount = 0; + let deletedReceiptCount = 0; + for (let cursor = 0; ;) { + const rows = yield* sql<{ readonly sequence: number; readonly imported: number }>` + SELECT + event.sequence, + event.aggregate_kind = 'thread' AND EXISTS ( + SELECT 1 FROM orchestration_v2_legacy_imports AS legacy_import + WHERE legacy_import.thread_id = event.stream_id + AND legacy_import.transcript_imported_at IS NOT NULL + ) AS imported + FROM orchestration_events AS event + INDEXED BY idx_orchestration_events_application_sequence + WHERE event.application_event_version = 1 + AND event.sequence > ${cursor} + AND event.sequence <= ${throughSequence} + ORDER BY event.sequence ASC + LIMIT ${PAGE_SIZE} + `.pipe(NodeSqliteClient.readOnly); + const obsolete = rows.filter((row) => row.imported === 1).map((row) => row.sequence); yield* Effect.yieldNow; if (obsolete.length > 0) { yield* sql`DELETE FROM orchestration_events WHERE sequence IN ${sql.in(obsolete)}`; deletedEventCount += obsolete.length; yield* Effect.yieldNow; } - throughSequence = - rows.length <= COMPACTION_PAGE_SIZE ? 0 : (page.at(-1)?.sequence ?? 1) - 1; - if (throughSequence > 0 || throughReceiptRowId > 0) yield* betweenPages; + yield* betweenPages; + if (rows.length < PAGE_SIZE) break; + cursor = rows.at(-1)?.sequence ?? cursor; } - - while (throughReceiptRowId > 0) { + for (let cursor = 0; ;) { const rows = yield* sql<{ readonly row_id: number; readonly command_id: string; - readonly imported_legacy_thread: number; + readonly imported: number; }>` SELECT receipt.rowid AS row_id, receipt.command_id, - CASE - WHEN receipt.command_type = 'legacy' AND receipt.aggregate_kind = 'thread' - THEN EXISTS ( - SELECT 1 FROM orchestration_v2_legacy_imports AS legacy_import - WHERE legacy_import.thread_id = receipt.aggregate_id - AND legacy_import.transcript_imported_at IS NOT NULL - ) - ELSE 0 - END AS imported_legacy_thread + receipt.command_type = 'legacy' AND receipt.aggregate_kind = 'thread' AND EXISTS ( + SELECT 1 FROM orchestration_v2_legacy_imports AS legacy_import + WHERE legacy_import.thread_id = receipt.aggregate_id + AND legacy_import.transcript_imported_at IS NOT NULL + ) AS imported FROM orchestration_command_receipts AS receipt - WHERE receipt.rowid <= ${throughReceiptRowId} - ORDER BY receipt.rowid DESC - LIMIT ${COMPACTION_PAGE_SIZE + 1} + WHERE receipt.rowid > ${cursor} AND receipt.rowid <= ${throughReceiptRowId} + ORDER BY receipt.rowid ASC + LIMIT ${PAGE_SIZE} `.pipe(NodeSqliteClient.readOnly); - const page = rows.slice(0, COMPACTION_PAGE_SIZE); - const obsolete = page - .filter((row) => row.imported_legacy_thread === 1) - .map((row) => row.command_id); + const obsolete = rows.filter((row) => row.imported === 1).map((row) => row.command_id); yield* Effect.yieldNow; if (obsolete.length > 0) { yield* sql`DELETE FROM orchestration_command_receipts WHERE command_id IN ${sql.in(obsolete)}`; deletedReceiptCount += obsolete.length; yield* Effect.yieldNow; } - throughReceiptRowId = (page.at(-1)?.row_id ?? 1) - 1; - if (rows.length <= COMPACTION_PAGE_SIZE) break; yield* betweenPages; + if (rows.length < PAGE_SIZE) break; + cursor = rows.at(-1)?.row_id ?? cursor; } + return { deletedEventCount, deletedReceiptCount }; + }); - const freelistRows = yield* sql<{ readonly freelist_count: number }>`PRAGMA freelist_count`; - const pageSizeRows = yield* sql<{ readonly page_size: number }>`PRAGMA page_size`; - const reclaimableBytes = - (freelistRows[0]?.freelist_count ?? 0) * (pageSizeRows[0]?.page_size ?? 0); + // A pass prunes through the mark the previous pass left once that mark is a day old, so + // at least RETAINED_EVENT_HISTORY of events always remains. Receipts stay for idempotency. + const pruneEventStore = Effect.fn("ProjectionMaintenance.pruneEventStore")( + function* (betweenPages: Effect.Effect = Effect.void) { + const now = yield* DateTime.now; + const metadata = yield* sql` + SELECT projection_name, last_sequence, updated_at + FROM orchestration_v2_projection_metadata + WHERE projection_name IN ( + 'thread-projections', ${EVENT_LOG_PRUNE_MARK}, ${EVENT_LOG_PRUNED_THROUGH} + ) + `.pipe(NodeSqliteClient.readOnly); + const read = (name: string) => metadata.find((row) => row.projection_name === name); + const projected = read("thread-projections")?.last_sequence ?? 0; + const mark = read(EVENT_LOG_PRUNE_MARK); + const prunedThrough = read(EVENT_LOG_PRUNED_THROUGH)?.last_sequence ?? 0; + // Fixed bounds: foreground commits during the pass wait for the next one. + const bounds = yield* sql<{ readonly sequence: number; readonly receipt_row_id: number }>` + SELECT + COALESCE((SELECT MAX(sequence) FROM orchestration_events), 0) AS sequence, + COALESCE((SELECT MAX(rowid) FROM orchestration_command_receipts), 0) AS receipt_row_id + `.pipe(NodeSqliteClient.readOnly); + const head = bounds[0]?.sequence ?? 0; - return { deletedEventCount, deletedReceiptCount, reclaimableBytes }; - }, mapError("compact event store")); + let deletedEventCount = 0; + const markIsDue = + mark !== undefined && + DateTime.toEpochMillis(now) - Date.parse(mark.updated_at) >= + Duration.toMillis(RETAINED_EVENT_HISTORY); + if (mark !== undefined && markIsDue) { + const throughSequence = Math.max(Math.min(mark.last_sequence, projected), prunedThrough); + if (throughSequence > prunedThrough) { + // Publish the boundary first so resuming clients take a snapshot, never a partial replay. + yield* writeMetadata(EVENT_LOG_PRUNED_THROUGH, throughSequence, now); + } + deletedEventCount += yield* pruneEvents(throughSequence, head, betweenPages); + } + if (mark === undefined || markIsDue) yield* writeMetadata(EVENT_LOG_PRUNE_MARK, head, now); - return ProjectionMaintenanceV2.of({ - verify: mapError("verify")(verify), - rebuild: mapError("rebuild")(rebuild), - compactEventStore, - }); + const legacy = yield* pruneImportedLegacyHistory( + head, + bounds[0]?.receipt_row_id ?? 0, + betweenPages, + ); + deletedEventCount += legacy.deletedEventCount; + + const freelistRows = yield* sql<{ readonly freelist_count: number }>`PRAGMA freelist_count`; + const pageSizeRows = yield* sql<{ readonly page_size: number }>`PRAGMA page_size`; + const reclaimableBytes = + (freelistRows[0]?.freelist_count ?? 0) * (pageSizeRows[0]?.page_size ?? 0); + + return { + deletedEventCount, + deletedReceiptCount: legacy.deletedReceiptCount, + reclaimableBytes, + }; + }, + Effect.mapError( + (cause) => new ProjectionMaintenanceError({ operation: "prune event store", cause }), + ), + ); + + return ProjectionMaintenanceV2.of({ pruneEventStore }); }), ); diff --git a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts index 4df71fd5a9d63..a4914fdee4509 100644 --- a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts @@ -22,7 +22,9 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; +import * as Statement from "effect/sql/Statement"; +import delegatedRecoveryIndexes from "../persistence/Migrations/079_DelegatedRecoveryIndexes.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; @@ -356,8 +358,28 @@ it.effect("selects unfinished recovery work without reading settled thread histo new Set(yield* projections.getRecoveryThreadIds("runtime")), new Set([queued, archived, blocked, background, outboxOnly, requestOnly]), ); - assert.deepEqual(yield* projections.getRecoveryThreadIds("delegated-completions"), [delivery]); - assert.deepEqual(yield* projections.getRecoveryThreadIds("subagent-results"), []); + for (const [kind, expected, index] of [ + ["delegated-completions", [delivery], "runs_delegated_delivery_idx"], + ["subagent-results", [], "threads_subagent_result_idx"], + ] as const) { + const statements: Array["compile"]>> = []; + const observe: Statement.Transformer = (statement) => { + statements.push(statement.compile()); + return Effect.succeed(statement); + }; + assert.deepEqual( + yield* projections + .getRecoveryThreadIds(kind) + .pipe(Effect.provideService(Statement.CurrentTransformer, observe)), + expected, + ); + const [query, params] = statements[0]!; + const plan = yield* sql.unsafe<{ detail: string }>(`EXPLAIN QUERY PLAN ${query}`, params); + assert.isTrue( + plan.some((row) => row.detail.includes(index)), + JSON.stringify(plan), + ); + } const recoveryState = yield* projections.getRuntimeRecoveryProjection(queued); assert.deepEqual( recoveryState.runs.map((run) => run.id), @@ -370,6 +392,84 @@ it.effect("selects unfinished recovery work without reading settled thread histo }).pipe(Effect.provide(layerTest)), ); +it.effect("maintains delegated candidate indexes across legacy rows and payload transitions", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const parent = yield* createThread("index-parent"); + const child = yield* createThread("index-child"); + const run = yield* createRun(child, "completed"); + const rows = yield* sql<{ payload_json: string }>` + SELECT payload_json FROM orchestration_v2_projection_threads WHERE thread_id = ${child} + `; + const original = JSON.parse(rows[0]!.payload_json); + for (const payload of [ + "{broken", + JSON.stringify({ + ...original, + lineage: { parentThreadId: parent, relationshipToParent: "subagent" }, + forkedFrom: { type: "provider_thread" }, + }), + JSON.stringify({ + ...original, + lineage: { parentThreadId: parent, relationshipToParent: "subagent" }, + forkedFrom: { type: "node", nodeId: "node:index" }, + }), + JSON.stringify({ + ...original, + lineage: { parentThreadId: child, relationshipToParent: "subagent" }, + forkedFrom: { type: "node", nodeId: "node:index" }, + }), + JSON.stringify(original), + ]) { + yield* sql`UPDATE orchestration_v2_projection_threads SET payload_json = ${payload} + WHERE thread_id = ${child}`; + const expected = payload.includes('"type":"node"') ? [child] : []; + assert.deepEqual(yield* projections.getRecoveryThreadIds("subagent-results"), expected); + } + for (const entry of [ + ["{broken", false], + ["{}", false], + ['{"delegatedCompletion":{"delivery":null}}', false], + ['{"delegatedCompletion":{"delivery":[]}}', false], + ['{"delegatedCompletion":{"delivery":{}}}', true], + ["{}", false], + ] as const) { + const [payload, included] = entry; + yield* sql`UPDATE orchestration_v2_projection_runs SET payload_json = ${payload} + WHERE run_id = ${run}`; + assert.deepEqual( + yield* projections.getRecoveryThreadIds("delegated-completions"), + included ? [child] : [], + ); + } + yield* sql`UPDATE orchestration_v2_projection_threads SET payload_json = '{broken' + WHERE thread_id = ${parent}`; + yield* sql`UPDATE orchestration_v2_projection_runs SET payload_json = '{broken' + WHERE run_id = ${run}`; + const existing = yield* createThread("index-existing", { + lineage: { parentThreadId: child, relationshipToParent: "subagent", rootThreadId: parent }, + forkedFrom: { type: "node", nodeId: NodeId.make("node:index-existing") }, + }); + yield* createRun(existing, "completed", { + delegatedCompletion: { + disposition: "open", + nextGeneration: 2, + delivery: { + generation: 1, + messageId: MessageId.make("message:index-existing"), + taskIds: [], + }, + }, + }); + yield* sql`DROP INDEX orchestration_v2_projection_threads_subagent_result_idx`; + yield* sql`DROP INDEX orchestration_v2_projection_runs_delegated_delivery_idx`; + yield* delegatedRecoveryIndexes; + assert.deepEqual(yield* projections.getRecoveryThreadIds("subagent-results"), [existing]); + assert.deepEqual(yield* projections.getRecoveryThreadIds("delegated-completions"), [existing]); + }).pipe(Effect.provide(layerTest)), +); + it.effect("recovers terminal subagent results until their cross-thread transfer exists", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; @@ -657,6 +757,251 @@ it.effect("includes shared sessions and provider-owned background rosters in rec }).pipe(Effect.provide(layerTest)), ); +it.effect("bounds streaming recovery reads by candidate runs and preserves message ordering", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + const threadId = yield* createThread("streaming"); + const other = yield* createThread("streaming-other"); + const empty = yield* createThread("streaming-empty"); + const statements: Array["compile"]>> = []; + const observe: Statement.Transformer = (statement) => { + const compiled = statement.compile(); + if (compiled[0].includes("SELECT message.payload_json")) statements.push(compiled); + return Effect.succeed(statement); + }; + const expected: MessageId[] = []; + const statuses = [ + "queued", + "preparing", + "starting", + "running", + "waiting", + "completed", + "interrupted", + "rolled_back", + "failed", + "cancelled", + ] as const; + for (const [index, status] of statuses.entries()) { + const runId = yield* createRun(threadId, status, { ordinal: index + 1 }); + for (const suffix of ["b", "a", "settled", "wrong-thread"]) { + const id = MessageId.make(`message:streaming:${index}:${suffix}`); + const streaming = suffix !== "settled"; + const messageThread = suffix === "wrong-thread" ? other : threadId; + const createdAt = DateTime.add(now, { seconds: -index }); + yield* projections.apply({ + id: EventId.make(`event:${id}`), + type: "message.updated", + threadId: messageThread, + runId, + occurredAt: now, + payload: { + id, + threadId: messageThread, + runId, + nodeId: null, + role: "assistant", + text: id, + attachments: [], + streaming, + createdAt, + updatedAt: now, + createdBy: "agent", + creationSource: "provider", + }, + }); + } + if (index < 5) + expected.unshift( + MessageId.make(`message:streaming:${index}:a`), + MessageId.make(`message:streaming:${index}:b`), + ); + } + const foreignRun = yield* createRun(other, "running"); + yield* sql` + INSERT INTO orchestration_v2_projection_messages + (message_id, thread_id, run_id, role, streaming, created_at, updated_at, payload_json) + VALUES ('foreign-run', ${threadId}, ${foreignRun}, 'assistant', 1, '', '', '{}') + `; + yield* sql` + INSERT INTO orchestration_v2_projection_messages + (message_id, thread_id, run_id, role, streaming, created_at, updated_at, payload_json) + VALUES ('old-history', ${empty}, NULL, 'assistant', 1, '', '', '{}') + `; + const projection = yield* projections + .getRuntimeRecoveryProjection(threadId) + .pipe(Effect.provideService(Statement.CurrentTransformer, observe)); + assert.deepEqual( + projection.messages.map((message) => message.id), + expected, + ); + const emptyProjection = yield* projections + .getRuntimeRecoveryProjection(empty) + .pipe(Effect.provideService(Statement.CurrentTransformer, observe)); + assert.deepEqual(emptyProjection.messages, []); + assert.equal(statements.length, 2); + for (const [query, params] of statements) { + const plan = yield* sql.unsafe<{ detail: string }>(`EXPLAIN QUERY PLAN ${query}`, params); + assert.isTrue(plan[0]?.detail.includes("SEARCH run "), JSON.stringify(plan)); + assert.isTrue( + plan.some((row) => row.detail.includes("messages_run_idx")), + JSON.stringify(plan), + ); + assert.isFalse( + plan.some((row) => row.detail.includes("messages_thread_created_idx")), + JSON.stringify(plan), + ); + } + }).pipe(Effect.provide(layerTest)), +); + +it.effect("bounds runtime provider-thread reads by ownership and preserves recovery liveness", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + const threadId = yield* createThread("ownership"); + const other = yield* createThread("other-owner"); + const ownerNodeId = NodeId.make("node:recovery:owner"); + yield* projections.apply({ + id: EventId.make("event:recovery:owner"), + type: "node.updated", + threadId, + nodeId: ownerNodeId, + driver, + occurredAt: now, + payload: { + id: ownerNodeId, + threadId, + runId: null, + parentNodeId: null, + rootNodeId: ownerNodeId, + kind: "root_turn", + status: "completed", + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: now, + }, + }); + const cases = [ + { name: "direct", ownership: "direct", status: "active", ordinal: 2, included: true }, + { name: "owner", ownership: "owner", status: "active", ordinal: null, included: true }, + { name: "owner-only", ownership: "owner", status: "active", ordinal: 1, included: true }, + { name: "subagent", ownership: "subagent", status: "active", ordinal: 1, included: true }, + { name: "duplicate", ownership: "all", status: "active", ordinal: 1, included: true }, + { name: "roster", ownership: "direct", status: "idle", ordinal: 3, included: true }, + { name: "run", ownership: "owner", status: "idle", ordinal: 4, included: true }, + { name: "item", ownership: "subagent", status: "idle", ordinal: 5, included: true }, + { name: "waiting-run", ownership: "direct", status: "idle", ordinal: 6, included: true }, + { name: "settled", ownership: "all", status: "idle", ordinal: 7, included: false }, + { name: "unrelated", ownership: "none", status: "active", ordinal: 8, included: false }, + { name: "unrelated-run", ownership: "none", status: "idle", ordinal: 9, included: false }, + ] as const; + for (const entry of cases) { + const id = ProviderThreadId.make(`provider-thread:ownership:${entry.name}`); + yield* projections.apply({ + id: EventId.make(`event:${id}`), + type: "provider-thread.updated", + threadId, + driver, + providerInstanceId, + occurredAt: now, + payload: { + id, + appThreadId: + entry.name === "owner-only" + ? null + : entry.ownership === "direct" || entry.ownership === "all" + ? threadId + : other, + ownerNodeId: + entry.ownership === "owner" || entry.ownership === "all" ? ownerNodeId : null, + driver, + providerInstanceId, + providerSessionId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: entry.status, + firstRunOrdinal: entry.ordinal, + lastRunOrdinal: entry.ordinal, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + pendingBackgroundTasks: + entry.name === "roster" + ? [{ taskId: "shell", description: "background", kind: "command" }] + : [], + }, + }); + if (entry.ownership === "subagent" || entry.ownership === "all") { + for (const suffix of ["a", "b"]) { + yield* sql` + INSERT INTO orchestration_v2_projection_subagents + (subagent_id, thread_id, parent_node_id, provider, provider_thread_id, + origin, status, updated_at, payload_json) + VALUES (${`${id}:${suffix}`}, ${threadId}, ${ownerNodeId}, 'codex', ${id}, + 'provider_native', 'completed', '', '{}') + `; + } + } + if (entry.name === "run" || entry.name === "unrelated-run") { + yield* createRun(threadId, "waiting", { ordinal: entry.ordinal, providerThreadId: id }); + } + } + const waitingProviderThreadId = ProviderThreadId.make("provider-thread:ownership:waiting-run"); + const waitingRunId = yield* createRun(threadId, "completed", { + ordinal: 6, + providerThreadId: waitingProviderThreadId, + }); + yield* sql` + INSERT INTO orchestration_v2_projection_turn_items + (turn_item_id, thread_id, run_id, node_id, provider_thread_id, ordinal, type, + status, updated_at, payload_json) + VALUES ('item:ownership', ${threadId}, ${waitingRunId}, ${ownerNodeId}, + ${ProviderThreadId.make("provider-thread:ownership:item")}, 1, 'assistant_message', 'waiting', '', '{}') + `; + const statements: Array["compile"]>> = []; + const observe: Statement.Transformer = (statement) => { + const compiled = statement.compile(); + if (compiled[0].includes("SELECT provider_thread.payload_json")) statements.push(compiled); + return Effect.succeed(statement); + }; + const projection = yield* projections + .getRuntimeRecoveryProjection(threadId) + .pipe(Effect.provideService(Statement.CurrentTransformer, observe)); + assert.deepEqual( + projection.providerThreads.map((row) => row.id), + cases + .filter((entry) => entry.included) + .toSorted((a, b) => (a.ordinal ?? 0) - (b.ordinal ?? 0) || a.name.localeCompare(b.name)) + .map((entry) => ProviderThreadId.make(`provider-thread:ownership:${entry.name}`)), + ); + assert.equal(statements.length, 1); + const [query, params] = statements[0]!; + const plan = yield* sql.unsafe<{ detail: string }>(`EXPLAIN QUERY PLAN ${query}`, params); + assert.isFalse( + plan.some((row) => /^SCAN provider_thread(?:$| )/.test(row.detail)), + JSON.stringify(plan), + ); + assert.isTrue( + plan.some((row) => row.detail.includes("provider_threads_owner_idx")), + JSON.stringify(plan), + ); + assert.isFalse( + plan.some((row) => /SEARCH (owner|n) USING INDEX .*nodes_thread/.test(row.detail)), + JSON.stringify(plan), + ); + }).pipe(Effect.provide(layerTest)), +); + it.effect("reads the run that owns a background roster, not a queued or resumed one", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 797de5b5e0dff..38dee18481717 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1,9 +1,10 @@ -import { assert, it } from "@effect/vitest"; +import { assert, it, vi } from "@effect/vitest"; import { EnvironmentId, EventId, CommandId, CheckpointId, + ContextTransferId, CheckpointRef, CheckpointScopeId, MessageId, @@ -27,13 +28,18 @@ import { TurnItemId, type ThreadPullRequestLink, isLiveSubagentShell, + isListedThreadLineage, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/sql/SqlClient"; +import * as Statement from "effect/sql/Statement"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; @@ -47,6 +53,9 @@ import { THREAD_HISTORY_PAGE_POLICY, THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT, } from "./threadHistoryPaging.ts"; +import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; + +import { layerReaderDatabase, traceSqlRouting } from "../persistence/testkit/SqliteRouting.ts"; const layerTest = Layer.mergeAll( ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)), @@ -62,6 +71,7 @@ const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.U const traceSqlStatements = () => { const statements: Array = []; + const queryTransactions: Array = []; const tracer = Tracer.make({ span(options) { const span = new Tracer.NativeSpan(options); @@ -69,19 +79,58 @@ const traceSqlStatements = () => { span.end = (endTime, exit) => { end(endTime, exit); const query = span.attributes.get("db.query.text"); - if (typeof query === "string") statements.push(query); + if (typeof query === "string") { + statements.push(query); + let parent = Option.getOrUndefined(span.parent); + while (parent?._tag === "Span" && parent.name !== "sql.transaction") { + parent = Option.getOrUndefined(parent.parent); + } + queryTransactions.push(parent?._tag === "Span" ? parent.spanId : null); + } }; return span; }, }); - return { statements, tracer }; + return { statements, tracer, queryTransactions }; }; /** The shell index flags every live shell and matches its shells on the fields subscriber trims read. */ const assertShellIndexCoversShells = Effect.fnUntraced(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; - const shells = (yield* store.getShellSnapshot({ location: "active" })).threads; + const snapshot = yield* store.getShellSnapshot(); + const shells = snapshot.threads; const index = yield* store.listActiveShellIndex(); + const { tracer, statements, queryTransactions } = traceSqlStatements(); + const activity = yield* store.getAgentAwarenessSnapshot().pipe(Effect.withTracer(tracer)); + assert.equal(activity.snapshotSequence, snapshot.snapshotSequence); + const awareness = (thread: Parameters[0]["thread"]) => + projectThreadAwarenessV2({ + environmentId: EnvironmentId.make("test"), + project: { title: "Test" }, + thread, + }); + assert.deepEqual( + activity.threads.map(awareness), + shells.filter((thread) => isListedThreadLineage(thread.lineage)).map(awareness), + ); + assert.deepEqual( + activity.threads.map((thread) => [thread.id, thread.projectId, thread.latestRunCompletedAt]), + shells + .filter((thread) => isListedThreadLineage(thread.lineage)) + .map((thread) => [thread.id, thread.projectId, thread.latestRunCompletedAt]), + ); + assert.isFalse(statements.some((statement) => /COUNT\(\*\)|GROUP BY/i.test(statement))); + assert.isTrue(queryTransactions.every((transaction) => transaction !== null)); + assert.equal(new Set(queryTransactions).size, 1); + for (const shell of [...shells, ...snapshot.archivedThreads]) { + const targeted = yield* store + .getAgentAwarenessSnapshot(shell.id) + .pipe(Effect.withTracer(tracer)); + assert.deepEqual(targeted.threads.map(awareness), [awareness(shell)]); + assert.deepEqual(targeted.threads[0]?.archivedAt, shell.archivedAt); + } + assert.isFalse(statements.some((statement) => /COUNT\(\*\)|GROUP BY/i.test(statement))); + const iso = (value: DateTime.Utc | null | undefined) => value == null ? null : DateTime.formatIso(value); const trimmed = ( @@ -215,6 +264,392 @@ const addRolledBackRecoveryCandidate = Effect.fn("addRolledBackRecoveryCandidate return threadId; }); +it.effect("routes full projection reads to readers and retains uncommitted writer state", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const parentId = yield* addRolledBackRecoveryCandidate("reader-routing"); + const parent = yield* store.getThreadProjection(parentId); + const threadId = ThreadId.make("thread:reader-routing:fork"); + yield* store.apply({ + id: EventId.make("event:reader-routing:fork"), + type: "thread.created", + threadId, + occurredAt: parent.thread.createdAt, + payload: { + ...parent.thread, + id: threadId, + forkedFrom: { type: "run", threadId: parentId, runId: parent.runs[0]!.id }, + }, + }); + const messageId = MessageId.make("message:reader-routing"); + yield* store.apply({ + id: EventId.make("event:reader-routing:message"), + type: "message.updated", + threadId, + occurredAt: parent.thread.createdAt, + payload: { + id: messageId, + threadId, + runId: null, + nodeId: null, + role: "user", + text: "Committed message", + attachments: [], + streaming: false, + createdBy: "user", + creationSource: "web", + createdAt: parent.thread.createdAt, + updatedAt: parent.thread.createdAt, + }, + }); + const { queries, trace } = traceSqlRouting(); + yield* store.getAgentAwarenessSnapshot().pipe(trace); + const projection = yield* store.getThreadProjection(threadId).pipe(trace); + assert.equal(projection.thread.id, threadId); + assert.deepEqual( + projection.visibleTurnItems + .filter((row) => row.visibility === "inherited") + .map((row) => row.sourceItemId), + parent.turnItems.map((item) => item.id), + ); + assert.equal( + (yield* store.getMessagesById(threadId, [messageId]).pipe(trace)).get(messageId)?.text, + "Committed message", + ); + assert.isAbove(queries.length, 1); + assert.deepEqual([...new Set(queries.map((row) => row.connection))], ["reader"]); + const commitRequested = yield* Deferred.make(); + const writer = yield* Deferred.await(commitRequested).pipe( + Effect.andThen( + sql.withTransaction( + Effect.gen(function* () { + yield* sql` + UPDATE orchestration_v2_projection_threads + SET payload_json = json_set(payload_json, '$.title', 'Committed title') + WHERE thread_id = ${threadId} + `; + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET payload_json = json_set(payload_json, '$.title', 'Committed command') + WHERE thread_id = ${parentId} + `; + }), + ), + ), + Effect.forkChild, + ); + let commitInjected = false; + const duringCommit = yield* store.getThreadProjection(threadId).pipe( + Effect.provideService(Statement.CurrentTransformer, (statement) => { + const [query] = statement.compile(); + if (!commitInjected && query.includes("FROM orchestration_v2_projection_turn_items")) { + commitInjected = true; + return Deferred.succeed(commitRequested, undefined).pipe( + Effect.andThen(Fiber.join(writer).pipe(Effect.orDie)), + Effect.as(statement), + ); + } + return Effect.succeed(statement); + }), + ); + assert.isTrue(commitInjected); + assert.deepEqual(duringCommit, projection); + const afterCommit = yield* store.getThreadProjection(threadId); + assert.equal(afterCommit.thread.title, "Committed title"); + assert.equal( + afterCommit.visibleTurnItems.find((row) => row.visibility === "inherited")?.item.title, + "Committed command", + ); + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql` + UPDATE orchestration_v2_projection_threads + SET payload_json = json_set(payload_json, '$.title', 'Uncommitted title') + WHERE thread_id = ${threadId} + `; + yield* sql` + UPDATE orchestration_v2_projection_messages + SET payload_json = json_set(payload_json, '$.text', 'Uncommitted message') + WHERE message_id = ${messageId} + `; + assert.equal( + (yield* store.getMessagesById(threadId, [messageId])).get(messageId)?.text, + "Uncommitted message", + ); + assert.equal( + (yield* store.getAgentAwarenessSnapshot()).threads.find( + (thread) => thread.id === threadId, + )?.title, + "Uncommitted title", + ); + assert.equal( + (yield* store.getThreadProjection(threadId)).thread.title, + "Uncommitted title", + ); + }), + ); + }).pipe(Effect.provide(ProjectionStore.layer.pipe(Layer.provideMerge(layerReaderDatabase)))), +); + +it.effect("record reads restrict provider histories to delivery identities", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* addRolledBackRecoveryCandidate("delivery-identities"); + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + for (const suffix of ["selected", "history"]) { + const id = ProviderThreadId.make(`provider-thread:${suffix}`); + yield* store.apply({ + id: EventId.make(`event:delivery-identities:${suffix}`), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: { + id, + appThreadId: threadId, + driver, + providerInstanceId, + providerSessionId: null, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + status: "active", + firstRunOrdinal: 1, + createdAt: now, + updatedAt: now, + }, + }); + } + for (const [suffix, attempt] of [ + ["selected", "current"], + ["history", "old"], + ["runless", null], + ] as const) { + yield* store.apply({ + id: EventId.make(`event:delivery-turn:${suffix}`), + type: "provider-turn.updated", + threadId, + occurredAt: now, + payload: { + id: ProviderTurnId.make(`turn:${suffix}`), + providerThreadId: ProviderThreadId.make("provider-thread:selected"), + nodeId: NodeId.make("node:delivery"), + runAttemptId: attempt === null ? null : RunAttemptId.make(attempt), + nativeTurnRef: null, + ordinal: suffix === "selected" ? 1 : suffix === "history" ? 2 : 3, + status: "completed", + startedAt: now, + completedAt: now, + }, + }); + } + assert.deepEqual( + (yield* store.getThreadRecords(threadId, ["providerTurns"], { + providerTurnAttemptIds: [RunAttemptId.make("current")], + })).providerTurns.map((turn) => turn.id), + ["turn:selected"], + ); + assert.deepEqual( + (yield* store.getThreadRecords(threadId, ["providerTurns"], { + providerTurnAttemptIds: [], + })).providerTurns, + [], + ); + const selected = yield* store.getThreadRecords(threadId, ["providerThreads"], { + providerThreadIds: [ProviderThreadId.make("provider-thread:selected")], + }); + assert.deepEqual( + selected.providerThreads.map((row) => row.id), + ["provider-thread:selected"], + ); + assert.deepEqual( + (yield* store.getThreadRecords(threadId, ["providerThreads"], { + providerThreadIds: [], + })).providerThreads, + [], + ); + // Unselected history must not be decoded, even if its payload is unreadable. + yield* sql`UPDATE orchestration_v2_projection_provider_threads SET payload_json = '{}' + WHERE provider_thread_id = 'provider-thread:history'`; + assert.lengthOf( + (yield* store.getThreadRecords(threadId, ["providerThreads"], { + providerThreadIds: [ProviderThreadId.make("provider-thread:selected")], + })).providerThreads, + 1, + ); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("idle compaction filters keep the latest run, live work and the newest usage", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + // Its rolled-back run 1 still has a running item, which background work must ignore. + const threadId = yield* addRolledBackRecoveryCandidate("idle-compaction"); + const now = yield* DateTime.now; + const minutes = (count: number) => DateTime.add(now, { minutes: count }); + const selected = ProviderThreadId.make("provider-thread:idle-selected"); + const nodeId = NodeId.make("node:idle-compaction"); + for (const [ordinal, status] of [ + [2, "completed"], + [3, "completed"], + [4, "failed"], + // A removed or steered queued message. + [5, "cancelled"], + ] as const) { + const runId = RunId.make(`run:idle-compaction:${ordinal}`); + yield* store.apply({ + id: EventId.make(`event:idle-compaction:run-${ordinal}`), + type: "run.created", + threadId, + runId, + nodeId, + driver, + providerInstanceId, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal, + providerInstanceId, + modelSelection, + providerThreadId: selected, + userMessageId: MessageId.make(`message:idle-compaction:${ordinal}`), + rootNodeId: nodeId, + activeAttemptId: null, + status, + requestedAt: now, + startedAt: status === "cancelled" ? null : now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }, + }); + } + for (const [id, providerThreadId, ordinal, usageAt] of [ + ["turn:idle-old", selected, 1, minutes(1)], + ["turn:idle-newest", selected, 2, minutes(3)], + ["turn:idle-later-ordinal", selected, 3, minutes(2)], + ["turn:idle-no-usage", selected, 4, null], + [ + "turn:idle-other-thread", + ProviderThreadId.make("provider-thread:idle-other"), + 1, + minutes(9), + ], + ] as const) { + yield* store.apply({ + id: EventId.make(`event:${id}`), + type: "provider-turn.updated", + threadId, + nodeId, + driver, + occurredAt: now, + payload: { + id: ProviderTurnId.make(id), + providerThreadId, + nodeId, + runAttemptId: null, + nativeTurnRef: null, + ordinal, + status: "completed", + startedAt: now, + completedAt: now, + ...(usageAt === null + ? {} + : { tokenUsage: { usedTokens: 150_000, updatedAt: DateTime.formatIso(usageAt) } }), + }, + }); + } + for (const status of ["pending", "resolved"] as const) { + yield* store.apply({ + id: EventId.make(`event:idle-compaction:request-${status}`), + type: "runtime-request.updated", + threadId, + nodeId, + occurredAt: now, + payload: { + id: RuntimeRequestId.make(`request:idle-compaction:${status}`), + nodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status, + responseCapability: { type: "message" }, + createdAt: now, + resolvedAt: status === "pending" ? null : now, + }, + }); + } + for (const status of ["pending", "consumed"] as const) { + yield* store.apply({ + id: EventId.make(`event:idle-compaction:transfer-${status}`), + type: "context-transfer.created", + threadId, + occurredAt: now, + payload: { + id: ContextTransferId.make(`transfer:idle-compaction:${status}`), + type: "merge_back", + sourceThreadId: ThreadId.make("thread:idle-compaction:side"), + targetThreadId: threadId, + sourcePoint: { threadId: ThreadId.make("thread:idle-compaction:side") }, + basePoint: null, + sourceProviderInstanceId: providerInstanceId, + targetProviderInstanceId: providerInstanceId, + targetRunId: null, + status, + resolution: null, + createdBy: "user", + error: null, + createdAt: now, + updatedAt: now, + consumedAt: status === "pending" ? null : now, + }, + }); + } + + const records = yield* store.getThreadRecords( + threadId, + ["runs", "providerTurns", "runtimeRequests", "contextTransfers"], + { + runStatuses: ["preparing", "starting", "running", "rolled_back"], + includeLatestRun: true, + providerTurnThreadIds: [selected], + latestProviderTurnUsageOnly: true, + runtimeRequestStatuses: ["pending"], + contextTransferStatuses: ["pending"], + }, + ); + // The failed latest run stays, so a completed earlier run is not mistaken for the latest, + // and a cancelled queued run never counts as the latest. + assert.deepEqual( + records.runs.map((run) => run.id), + ["run:idle-compaction:rolled-back", "run:idle-compaction:4"], + ); + assert.deepEqual( + records.providerTurns.map((turn) => turn.id), + ["turn:idle-newest"], + ); + assert.deepEqual( + records.runtimeRequests.map((request) => request.id), + ["request:idle-compaction:pending"], + ); + assert.deepEqual( + records.contextTransfers.map((transfer) => transfer.id), + ["transfer:idle-compaction:pending"], + ); + const empty = yield* store.getThreadRecords(threadId, ["runs", "providerTurns"], { + runStatuses: [], + providerTurnThreadIds: [], + latestProviderTurnUsageOnly: true, + }); + assert.deepEqual([empty.runs, empty.providerTurns], [[], []]); + }).pipe(Effect.provide(layerTest)), +); + it.effect("record reads filter notification sources without dropping matching message bodies", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; @@ -789,7 +1224,7 @@ const threadWorktreePathsIncludeArchivedOnRequest = Effect.gen(function* () { creationSource: "web" as const, id: threadId, projectId: ProjectId.make("project:worktree-paths"), - title: id, + title: `Title for ${id}`, providerInstanceId, modelSelection, runtimeMode: "full-access" as const, @@ -836,6 +1271,17 @@ const threadWorktreePathsIncludeArchivedOnRequest = Effect.gen(function* () { { threadId: ThreadId.make("thread:active"), worktreePath: "/worktrees/active" }, { threadId: ThreadId.make("thread:archived"), worktreePath: "/worktrees/archived" }, ]); + assert.deepEqual( + yield* store.getThreadTitles([ + deleted.id, + ThreadId.make("thread:active"), + ThreadId.make("thread:archived"), + ]), + { + [ThreadId.make("thread:active")]: "Title for thread:active", + [ThreadId.make("thread:archived")]: "Title for thread:archived", + }, + ); }); const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { @@ -926,8 +1372,15 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { ["running", settledRun, "running", null], ["active-run-delivered", activeRun, "completed", "delivered"], ] as const; + // A follow-up reuses its task's child thread. + const childThreadIds: Partial> = { + delivered: ThreadId.make("thread:settled-subagents:child-a"), + running: ThreadId.make("thread:settled-subagents:child-a"), + "no-delivery": ThreadId.make("thread:settled-subagents:child-b"), + }; for (const [index, [name, run, status, delivery]] of subagents.entries()) { const id = NodeId.make(`node:settled-subagents:${name}`); + const childThreadId = childThreadIds[name] ?? null; yield* store.apply({ id: EventId.make(`event:settled-subagents:item:${name}`), type: "turn-item.updated", @@ -956,7 +1409,7 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { origin: "app_owned", driver, providerInstanceId, - childThreadId: null, + childThreadId, prompt: "Do the work", result: null, }, @@ -980,7 +1433,7 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { driver, providerInstanceId, providerThreadId: null, - childThreadId: null, + childThreadId, nativeTaskRef: null, prompt: "Do the work", title: name, @@ -1030,6 +1483,10 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { ["delivered"], ); assert.strictEqual(yield* store.getSubagentCount(threadId), subagents.length); + assert.deepEqual( + [...(yield* store.listAppOwnedChildThreadIds(threadId))], + [childThreadIds.delivered, childThreadIds["no-delivery"]], + ); }); const backgroundThreadUpdatesKeepTimestamp = Effect.gen(function* () { @@ -1374,6 +1831,17 @@ const assertProjectScopedShells = Effect.fn("assertProjectScopedShells")(functio all.archivedThreads.map((thread) => thread.id), [archivedThreadId], ); + const missingThreadId = ThreadId.make(`thread:${suffix}:missing`); + const targeted = yield* projectionStore.getShellSnapshot({ + threadIds: [forkThreadId, archivedThreadId, missingThreadId], + }); + for (const threadId of [forkThreadId, archivedThreadId, missingThreadId]) { + assert.deepEqual( + [...targeted.threads, ...targeted.archivedThreads].find((shell) => shell.id === threadId) ?? + null, + yield* projectionStore.getThreadShell(threadId), + ); + } return { projectId, activeThreadId, forkThreadId, otherThreadId }; }); @@ -1555,6 +2023,226 @@ it.effect("SQL list older settled threads below a cursor", () => }).pipe(Effect.provide(layerTest)), ); +// regression: #25806 pending request cards outlive the history window that drops their turn +it.effect("SQL snapshot windows keep cards for requests still pending", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:pending-request-window"); + yield* store.apply({ + id: EventId.make("event:pending-request-window:thread"), + type: "thread.created", + threadId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project:pending-request-window"), + title: "Pending request window", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }); + let ordinal = 0; + const itemBase = (runId: RunId, nodeId: NodeId, id: string) => ({ + id: TurnItemId.make(`item:pending-request-window:${id}`), + threadId, + runId, + nodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: ++ordinal, + status: "completed" as const, + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + }); + const request = (id: string, nodeId: NodeId, kind: "user_input" | "command") => ({ + id: RuntimeRequestId.make(`request:pending-request-window:${id}`), + nodeId, + providerTurnId: null, + nativeRequestRef: null, + kind, + responseCapability: { + type: "live" as const, + providerSessionId: ProviderSessionId.make("session:pending-request-window"), + }, + createdAt: now, + }); + const turnCount = THREAD_HISTORY_PAGE_POLICY.maxUserTurns + 4; + for (let turn = 1; turn <= turnCount; turn += 1) { + const runId = RunId.make(`run:pending-request-window:${turn}`); + const nodeId = NodeId.make(`node:pending-request-window:${turn}`); + yield* store.apply({ + id: EventId.make(`event:pending-request-window:run:${turn}`), + type: "run.created", + threadId, + runId, + nodeId, + driver, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: turn, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make(`message:pending-request-window:${turn}`), + rootNodeId: nodeId, + activeAttemptId: null, + status: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }, + }); + const items: Array = [ + { + ...itemBase(runId, nodeId, `message:${turn}`), + type: "user_message", + createdBy: "user", + creationSource: "web", + messageId: MessageId.make(`message:pending-request-window:${turn}`), + inputIntent: "turn_start", + text: `Turn ${turn}`, + attachments: [], + }, + ]; + if (turn === 1) { + const question = { + id: "scope", + header: "Scope", + question: "Which scope?", + options: [{ label: "All", description: "Everything" }], + }; + items.push( + { + ...itemBase(runId, nodeId, "question:pending"), + status: "running", + type: "user_input_request", + requestId: request("question:pending", nodeId, "user_input").id, + questions: [question], + }, + { + ...itemBase(runId, nodeId, "approval:pending"), + status: "running", + type: "approval_request", + requestId: request("approval:pending", nodeId, "command").id, + requestKind: "command", + prompt: "rm -rf build", + }, + { + ...itemBase(runId, nodeId, "question:resolved"), + type: "user_input_request", + requestId: request("question:resolved", nodeId, "user_input").id, + questions: [question], + }, + ); + for (const [id, kind, status] of [ + ["question:pending", "user_input", "pending"], + ["approval:pending", "command", "pending"], + ["question:resolved", "user_input", "resolved"], + ] as const) { + yield* store.apply({ + id: EventId.make(`event:pending-request-window:request:${id}`), + type: "runtime-request.updated", + threadId, + nodeId, + driver, + occurredAt: now, + payload: { + ...request(id, nodeId, kind), + status, + resolvedAt: status === "pending" ? null : now, + }, + }); + } + } + for (const item of items) { + yield* store.apply({ + id: EventId.make(`event:pending-request-window:${item.id}`), + type: "turn-item.updated", + threadId, + runId, + nodeId, + driver, + occurredAt: now, + payload: item, + }); + } + } + + const pendingCardIds = [ + TurnItemId.make("item:pending-request-window:question:pending"), + TurnItemId.make("item:pending-request-window:approval:pending"), + ]; + const { statements, tracer } = traceSqlStatements(); + const snapshot = yield* store + .getThreadSnapshotWindow(threadId, { + rowLimit: THREAD_HISTORY_PAGE_POLICY.maxItems + 2, + userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, + }) + .pipe(Effect.withTracer(tracer)); + const sql = yield* SqlClient.SqlClient; + const query = statements.find( + (statement) => + statement.includes("FROM orchestration_v2_projection_runtime_requests AS request") && + statement.includes("SELECT item.payload_json"), + ); + assert.isDefined(query); + const plan = (yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${query}`, + )).map((row) => row.detail); + assert.include( + plan[0], + "SEARCH request USING INDEX orchestration_v2_projection_runtime_requests_thread_status_idx", + ); + assert.include( + plan[1], + "SEARCH item USING INDEX orchestration_v2_projection_turn_items_node_ordinal_idx", + ); + const bounded = buildBoundedThreadProjection({ + projection: snapshot.projection, + snapshotSequence: snapshot.snapshotSequence, + }); + assert.isTrue(bounded.hasMoreHistory); + assert.notInclude( + bounded.projection.turnItems.map((item) => item.id), + TurnItemId.make("item:pending-request-window:question:resolved"), + ); + for (const projection of [snapshot.projection, bounded.projection]) { + const visibleIds = projection.visibleTurnItems.map((row) => row.sourceItemId); + assert.notInclude(visibleIds, TurnItemId.make("item:pending-request-window:message:1")); + assert.includeMembers( + projection.turnItems.map((item) => item.id), + pendingCardIds, + ); + for (const id of pendingCardIds) assert.notInclude(visibleIds, id); + } + }).pipe(Effect.provide(layerTest)), +); + it.effect("lists only active subagent IDs without loading shell data", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; @@ -1675,12 +2363,101 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); + // regression: #25157 streamed payloads are not re-parsed to read their indexed timestamps + it.effect("indexes streamed payload timestamps without parsing the payload JSON", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const threadId = yield* addRolledBackRecoveryCandidate("streamed-payload-parse"); + const runId = RunId.make("run:streamed-payload-parse:rolled-back"); + const createdAt = DateTime.makeUnsafe("2026-01-02T03:04:05.006Z"); + const updatedAt = DateTime.makeUnsafe("2026-01-02T03:04:06.007Z"); + const text = "x".repeat(128 * 1024); + const messageId = MessageId.make("message:streamed-payload-parse"); + const itemId = TurnItemId.make("item:streamed-payload-parse"); + + const parsedPayloads = yield* Effect.acquireUseRelease( + Effect.sync(() => vi.spyOn(JSON, "parse")), + (parse) => + Effect.gen(function* () { + yield* store.apply({ + id: EventId.make("event:streamed-payload-parse:message"), + type: "message.updated", + threadId, + occurredAt: updatedAt, + payload: { + createdBy: "agent", + creationSource: "server", + id: messageId, + threadId, + runId, + nodeId: null, + role: "assistant", + text, + attachments: [], + streaming: true, + createdAt, + updatedAt, + }, + }); + yield* store.apply({ + id: EventId.make("event:streamed-payload-parse:item"), + type: "turn-item.updated", + threadId, + runId, + occurredAt: updatedAt, + payload: { + id: itemId, + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + type: "assistant_message", + status: "running", + title: "Assistant", + messageId, + text, + streaming: true, + startedAt: createdAt, + completedAt: null, + updatedAt, + }, + }); + return parse.mock.calls + .filter(([json]) => typeof json === "string" && json.includes(text)) + .map(([json]) => (JSON.parse(json) as { readonly id: string }).id); + }), + (parse) => Effect.sync(() => parse.mockRestore()), + ); + + assert.deepStrictEqual(parsedPayloads, []); + assert.deepStrictEqual( + yield* sql` + SELECT created_at, updated_at FROM orchestration_v2_projection_messages + WHERE message_id = ${messageId} + `, + [{ created_at: DateTime.formatIso(createdAt), updated_at: DateTime.formatIso(updatedAt) }], + ); + assert.deepStrictEqual( + yield* sql` + SELECT updated_at FROM orchestration_v2_projection_turn_items + WHERE turn_item_id = ${itemId} + `, + [{ updated_at: DateTime.formatIso(updatedAt) }], + ); + }), + ); + it.effect( "SQL recovery selects unfinished runless native subagents", () => runlessNativeSubagentNeedsRecovery, ); it.effect( - "worktree path lookup defaults to active threads and can include archived threads", + "worktree path and title lookups skip deleted threads, and can include archived ones", () => threadWorktreePathsIncludeArchivedOnRequest, ); @@ -2097,6 +2874,87 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { byteIds, Array.from({ length: 70 }, (_, index) => `turn-item:bounded-sql-history:${index + 1}`), ); + + // Selected message reads recover ordinals for only the loaded messages, while 69 + // unloaded assistant messages remain in the thread's history. + const firstMessageId = MessageId.make("message:bounded-sql-history:1"); + const lastMessageId = MessageId.make("message:bounded-sql-history:1000"); + const insertMessageItem = ( + suffix: string, + itemThreadId: string, + ordinal: number, + messageId: string | null | undefined, + ) => sql` + INSERT INTO orchestration_v2_projection_turn_items ( + turn_item_id, thread_id, run_id, node_id, provider_thread_id, provider_turn_id, + parent_item_id, ordinal, type, status, updated_at, payload_json + ) VALUES ( + ${`turn-item:bounded-sql-history:ordinal-${suffix}`}, ${itemThreadId}, NULL, NULL, NULL, + NULL, NULL, ${ordinal}, 'assistant_message', 'completed', ${nowIso}, + ${encodeUnknownJsonString({ messageId })} + ) + `; + yield* insertMessageItem("duplicate", threadId, 0, lastMessageId); + yield* insertMessageItem( + "other-thread", + "thread:bounded-sql-history:other", + -1, + firstMessageId, + ); + yield* insertMessageItem("null-id", threadId, -2, null); + yield* insertMessageItem("missing-id", threadId, -3, undefined); + const absentMessageId = MessageId.make("message:bounded-sql-history:absent"); + const messageReadTrace = traceSqlStatements(); + const selected = yield* projectionStore + .getMessagesById(threadId, [firstMessageId, lastMessageId, firstMessageId, absentMessageId]) + .pipe(Effect.withTracer(messageReadTrace.tracer)); + assert.isFalse(messageReadTrace.statements.some((query) => query.includes("turn_items"))); + assert.equal(selected.size, 2); + assert.equal(selected.get(firstMessageId)?.id, firstMessageId); + assert.equal(selected.get(lastMessageId)?.id, lastMessageId); + assert.isFalse(selected.has(absentMessageId)); + assert.equal((yield* projectionStore.getMessagesById(threadId, [])).size, 0); + assert.equal( + (yield* projectionStore.getMessagesById(ThreadId.make("other-thread"), [firstMessageId])) + .size, + 0, + ); + const readMessageIds = (filter?: ProjectionStore.ProjectionRecordFilter) => + projectionStore + .getThreadRecords(threadId, ["messages"], filter) + .pipe(Effect.map(({ messages }) => messages.map((message) => message.id))); + const canonical = yield* projectionStore.getThreadRecords(threadId, ["messages"]); + for (const message of canonical.messages) assert.deepEqual(selected.get(message.id), message); + assert.deepEqual(yield* readMessageIds(), [lastMessageId, firstMessageId]); + assert.deepEqual(yield* readMessageIds({ messageRoles: ["user", "assistant"] }), [ + lastMessageId, + firstMessageId, + ]); + for (const filter of [ + { + messageIds: [ + firstMessageId, + lastMessageId, + MessageId.make("message:bounded-sql-history:absent"), + ], + }, + { messageRoles: ["user" as const] }, + { + messageRunIds: [ + RunId.make("run:bounded-sql-history:1"), + RunId.make("run:bounded-sql-history:1000"), + ], + }, + ]) { + assert.deepEqual(yield* readMessageIds(filter), [lastMessageId, firstMessageId]); + } + assert.deepEqual(yield* readMessageIds({ messageIds: [] }), []); + assert.deepEqual(yield* readMessageIds({ messageIds: [firstMessageId] }), [firstMessageId]); + yield* sql` + DELETE FROM orchestration_v2_projection_turn_items + WHERE turn_item_id LIKE 'turn-item:bounded-sql-history:ordinal-%' + `; + yield* sql` UPDATE orchestration_v2_projection_turn_items SET type = 'command_execution', payload_json = json_set(payload_json, '$.type', 'command_execution') @@ -3359,6 +4217,76 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { .filter((id) => id === readThreadId || id === laterThreadId); yield* createThread(readThreadId); + yield* sql`UPDATE orchestration_v2_projection_threads + SET payload_json = json_set(payload_json, '$.linkedPullRequest', json(${JSON.stringify({ + projectId: "project:shell-read", + repository: "acme/project", + number: 42, + url: "https://github.com/acme/project/pull/42", + })})) WHERE thread_id = ${readThreadId}`; + const trace = traceSqlStatements(); + const discovery = yield* projectionStore + .getPullRequestSnapshot({}) + .pipe(Effect.withTracer(trace.tracer)); + assert.deepEqual( + discovery.threads + .map((thread) => thread.id) + .filter((id) => id === readThreadId || id === laterThreadId), + [readThreadId], + ); + assert.strictEqual(discovery.snapshotSequence, 0); + assert.strictEqual(trace.queryTransactions.length, 2); + assert.isNotNull(trace.queryTransactions[0]); + assert.strictEqual(new Set(trace.queryTransactions).size, 1); + assert.isFalse( + trace.statements.some((query) => + /orchestration_v2_projection_(runs|messages|turn_items|provider_threads)/.test(query), + ), + ); + const fullShell = (yield* projectionStore.getShellSnapshot()).threads.find( + (thread) => thread.id === readThreadId, + )!; + const metadata = discovery.threads.find((thread) => thread.id === readThreadId)!; + for (const key of Object.keys(metadata) as Array) { + assert.deepEqual(metadata[key], fullShell[key]); + } + const narrow = yield* projectionStore.getPullRequestSnapshot({ threadId: readThreadId }); + assert.deepEqual( + narrow.threads, + discovery.threads.filter((thread) => thread.id === readThreadId), + ); + const settledPayload = (settledAt: string | null, archivedAt: string | null) => sql` + UPDATE orchestration_v2_projection_threads + SET payload_json = json_set(payload_json, '$.settledAt', ${settledAt}, '$.archivedAt', ${archivedAt}) + WHERE thread_id = ${readThreadId} + `; + yield* settledPayload(DateTime.formatIso(now), null); + assert.isFalse( + (yield* projectionStore.getPullRequestSnapshot({ unsettledOnly: true })).threads.some( + (thread) => thread.id === readThreadId, + ), + ); + assert.strictEqual( + (yield* projectionStore.getPullRequestSnapshot({ threadId: readThreadId })).threads.length, + 1, + ); + yield* settledPayload(null, DateTime.formatIso(now)); + assert.isFalse( + (yield* projectionStore.getPullRequestSnapshot({})).threads.some( + (thread) => thread.id === readThreadId, + ), + ); + assert.strictEqual( + (yield* projectionStore.getPullRequestSnapshot({ threadId: readThreadId })).threads.length, + 1, + ); + yield* sql`UPDATE orchestration_v2_projection_threads SET deleted_at = '2026-11-03T00:00:00.000Z' WHERE thread_id = ${readThreadId}`; + assert.deepEqual( + (yield* projectionStore.getPullRequestSnapshot({ threadId: readThreadId })).threads, + [], + ); + yield* sql`UPDATE orchestration_v2_projection_threads SET deleted_at = NULL WHERE thread_id = ${readThreadId}`; + yield* settledPayload(null, null); const decode = yield* sql.withTransaction(projectionStore.readShellSnapshot()); yield* createThread(laterThreadId); @@ -5116,6 +6044,183 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); + it.effect("does not freshly arm limit recovery for a subagent of a settled parent", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const resetAt = DateTime.formatIso(DateTime.add(now, { hours: 1 })); + const threadPayload = ( + threadId: ThreadId, + parentThreadId: ThreadId | null, + settledOverride: "settled" | null, + ) => + ({ + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project:settled-parent-limit"), + title: "Settled parent limit recovery", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId, + relationshipToParent: parentThreadId === null ? null : "subagent", + rootThreadId: parentThreadId ?? threadId, + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride, + settledAt: settledOverride === null ? null : now, + lastVisitedAt: null, + deletedAt: null, + }) as const; + const createThread = ( + threadId: ThreadId, + parentThreadId: ThreadId | null, + settledOverride: "settled" | null, + ) => + store.apply({ + id: EventId.make(`event:${threadId}:created`), + type: "thread.created", + threadId, + occurredAt: now, + payload: threadPayload(threadId, parentThreadId, settledOverride), + }); + const addLimitedChild = Effect.fnUntraced(function* (name: string, parentSettled: boolean) { + const parentId = ThreadId.make(`thread:settled-parent-limit:${name}:parent`); + const childId = ThreadId.make(`thread:settled-parent-limit:${name}:child`); + const runId = RunId.make(`run:settled-parent-limit:${name}`); + const rootNodeId = NodeId.make(`node:settled-parent-limit:${name}`); + yield* createThread(parentId, null, parentSettled ? "settled" : null); + yield* createThread(childId, parentId, null); + const run = { + id: runId, + threadId: childId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make(`message:settled-parent-limit:${name}`), + rootNodeId, + activeAttemptId: null, + status: "failed" as const, + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }; + yield* store.apply({ + id: EventId.make(`event:settled-parent-limit:${name}:run`), + type: "run.created", + threadId: childId, + runId, + nodeId: rootNodeId, + driver, + providerInstanceId, + occurredAt: now, + payload: run, + }); + yield* store.apply({ + id: EventId.make(`event:settled-parent-limit:${name}:error`), + type: "turn-item.updated", + threadId: childId, + occurredAt: now, + payload: { + id: TurnItemId.make(`item:settled-parent-limit:${name}:error`), + threadId: childId, + runId, + nodeId: rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "failed", + title: "Usage limit reached", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "error", + failure: { + class: "usage_limit", + message: "Plan limit reached.", + resetAt, + code: "usageLimitExceeded", + retryable: null, + }, + }, + }); + return childId; + }); + const settledParentChild = yield* addLimitedChild("settled", true); + const activeParentChild = yield* addLimitedChild("active", false); + for (const options of [ + { now, autoResume: true, snooze: false }, + { now, autoResume: false, snooze: true }, + ]) { + const armed = (yield* store.getLimitRecoveryCandidates(options)) + .filter((row) => row.id === settledParentChild || row.id === activeParentChild) + .filter( + (row) => + limitRecoveryCommand( + row, + options.autoResume, + DateTime.toEpochMillis(now), + options.snooze, + )?.type === "thread.metadata.update", + ) + .map((row) => row.id); + assert.deepEqual( + armed, + [activeParentChild], + `only the child of the unsettled parent is armed (${JSON.stringify(options)})`, + ); + } + // A recovery armed before the parent settled still resumes at its reset. + yield* store.apply({ + id: EventId.make("event:settled-parent-limit:settled:armed"), + type: "thread.metadata-updated", + threadId: settledParentChild, + occurredAt: now, + payload: { + ...threadPayload( + settledParentChild, + ThreadId.make("thread:settled-parent-limit:settled:parent"), + null, + ), + limitRecovery: { + runId: RunId.make("run:settled-parent-limit:settled"), + resetAt, + autoResume: true, + }, + }, + }); + const afterReset = DateTime.add(now, { hours: 2 }); + const resumed = (yield* store.getLimitRecoveryCandidates({ + now: afterReset, + autoResume: true, + snooze: false, + })).find((row) => row.id === settledParentChild); + assert.isDefined(resumed); + assert.isTrue( + limitRecoveryCommand( + resumed, + true, + DateTime.toEpochMillis(afterReset), + false, + )?.commandId.startsWith("limit-resume:"), + ); + }), + ); + it.effect("projects one shared provider session into multiple thread bindings", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; @@ -7231,6 +8336,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }); const project = { title: "Project" }; const environmentId = EnvironmentId.make("environment:held-completion"); + yield* assertShellIndexCoversShells(); const held = yield* store.getThreadShell(threadId); assert.equal( held && projectThreadAwarenessV2({ environmentId, project, thread: held })?.phase, @@ -7246,6 +8352,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { occurredAt: releasedAt, payload: { ...providerThread, pendingBackgroundTasks: [], updatedAt: releasedAt }, }); + yield* assertShellIndexCoversShells(); const released = yield* store.getThreadShell(threadId); const state = released && projectThreadAwarenessV2({ environmentId, project, thread: released }); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 383f6fe666711..6c1a0528504ad 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1,3 +1,4 @@ +import type { ProjectThreadAwarenessV2Input } from "@t3tools/shared/agentAwareness"; import { pendingGuestMessageCount } from "@t3tools/contracts"; import { applyThreadPullRequestLinkUpdate } from "../sourceControl/threadPullRequests.ts"; import { projectTurnItemForWire } from "./WireProjection.ts"; @@ -25,6 +26,7 @@ import type { OrchestrationV2PendingRuntimeRequestSummary, PlanId, OrchestrationV2ConversationMessage, + OrchestrationV2ContextTransfer, OrchestrationV2DomainEvent, OrchestrationV2OlderSettledCount, OrchestrationV2OlderSettledCursor, @@ -32,6 +34,7 @@ import type { OrchestrationV2ProviderThread, OrchestrationV2ProviderTurn, OrchestrationV2Run, + OrchestrationV2RuntimeRequest, OrchestrationV2Subagent, OrchestrationV2ThreadShellSnapshot, OrchestrationV2ShellThreadStatus, @@ -329,6 +332,8 @@ export interface ProjectionRuntimeResponseContext { } export interface ProjectionRecordFilter { + readonly providerThreadIds?: ReadonlyArray; + readonly providerTurnAttemptIds?: ReadonlyArray; readonly messageRoles?: ReadonlyArray; readonly monitorNotificationsOnly?: boolean; readonly turnItemRunId?: RunId; @@ -337,6 +342,14 @@ export interface ProjectionRecordFilter { readonly turnItemRunIds?: ReadonlyArray; readonly turnItemNodeIds?: ReadonlyArray; readonly runIds?: ReadonlyArray; + readonly runStatuses?: ReadonlyArray; + /** Keeps the thread's highest-ordinal run even when `runStatuses` excludes it. */ + readonly includeLatestRun?: true; + readonly providerTurnThreadIds?: ReadonlyArray; + /** Only the provider turn whose token usage was updated last. */ + readonly latestProviderTurnUsageOnly?: true; + readonly runtimeRequestStatuses?: ReadonlyArray; + readonly contextTransferStatuses?: ReadonlyArray; readonly subagentIds?: ReadonlyArray; readonly subagentRunIds?: ReadonlyArray; readonly nodeIds?: ReadonlyArray; @@ -382,6 +395,7 @@ export type ProjectionRecords = Pick< export interface ProjectionTimelinePageOptions { readonly afterPosition?: number; readonly itemId?: TurnItemId; + readonly attachmentId?: string; readonly view?: "messages" | "activity"; readonly limit: number; } @@ -448,6 +462,10 @@ export interface ProjectionStoreV2Shape { ) => Effect.Effect; readonly getMessageCount: (threadId: ThreadId) => Effect.Effect; readonly getSubagentCount: (threadId: ThreadId) => Effect.Effect; + /** Child threads of the thread's app-owned tasks, in task order, without decoding the tasks. */ + readonly listAppOwnedChildThreadIds: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionStoreV2Error>; readonly getNextTurnItemOrdinal: ( threadId: ThreadId, ) => Effect.Effect; @@ -455,6 +473,13 @@ export interface ProjectionStoreV2Shape { readonly threadId: ThreadId; readonly itemId: TurnItemId; }) => Effect.Effect; + readonly getMessagesById: ( + threadId: ThreadId, + messageIds: ReadonlyArray, + ) => Effect.Effect< + ReadonlyMap, + ProjectionStoreV2Error + >; readonly getThreadRecords: ( threadId: ThreadId, fields: ReadonlyArray, @@ -464,6 +489,22 @@ export interface ProjectionStoreV2Shape { readonly apply: ( event: OrchestrationV2DomainEvent, ) => Effect.Effect; + readonly getPullRequestSnapshot: (options: { + readonly threadId?: ThreadId; + readonly unsettledOnly?: boolean; + }) => Effect.Effect< + { + readonly snapshotSequence: number; + readonly threads: ReadonlyArray; + }, + ProjectionStoreV2Error + >; + readonly getAgentAwarenessSnapshot: ( + threadId?: ThreadId, + ) => Effect.Effect< + { readonly snapshotSequence: number; readonly threads: ReadonlyArray }, + ProjectionStoreV2Error + >; readonly getShellSnapshot: ( options?: ShellSnapshotOptions, ) => Effect.Effect; @@ -518,6 +559,10 @@ export interface ProjectionStoreV2Shape { readonly getThreadsWithPullRequests: ( threadId?: ThreadId, ) => Effect.Effect, ProjectionStoreV2Error>; + /** Titles of the given threads that exist, archived ones included. */ + readonly getThreadTitles: ( + threadIds: ReadonlyArray, + ) => Effect.Effect, ProjectionStoreV2Error>; readonly getThreadWorktreePaths: (options?: { readonly includeArchived?: boolean; }) => Effect.Effect< @@ -953,6 +998,17 @@ type ShellPendingTurnItemRow = PayloadRow & { readonly owning_provider_instance_id: string | null; }; +export type AgentAwarenessThread = ProjectThreadAwarenessV2Input["thread"] & + Pick< + OrchestrationV2ThreadShell, + | "projectId" + | "latestRunCompletedAt" + | "archivedAt" + | "activeRunId" + | "latestRunId" + | "hasActionableProposedPlan" + >; + type ShellThreadRow = { readonly thread_id: string; readonly payload_json: string; @@ -1065,6 +1121,8 @@ const encodePlanPayload = Schema.encodeEffect( const encodeTurnItemPayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2TurnItemJsonSchema), ); +// The codec every *Json payload schema uses for timestamps, so index columns match payload_json. +const encodePayloadTimestamp = Schema.encodeEffect(Schema.DateTimeUtcFromString); const encodeCheckpointScopePayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2CheckpointScopeJsonSchema), ); @@ -1078,6 +1136,37 @@ const encodeContextTransferPayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2ContextTransferJsonSchema), ); +const PullRequestThread = OrchestrationV2AppThreadJsonSchema.mapFields( + ({ + id, + projectId, + lineage, + branch, + worktreePath, + archivedAt, + settledOverride, + settledAt, + linkedPullRequest, + branchPullRequest, + pullRequests, + }) => ({ + id, + projectId, + lineage, + branch, + worktreePath, + archivedAt, + settledOverride, + settledAt, + linkedPullRequest, + branchPullRequest, + pullRequests, + }), +); +const decodePullRequestThread = Schema.decodeUnknownEffect( + Schema.fromJsonString(PullRequestThread), +); + const decodeThreadPayload = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2AppThreadJsonSchema), ); @@ -2436,7 +2525,8 @@ export const layer: Layer.Layer = } case "message.updated": { const payloadJson = yield* encodeMessagePayload(event.payload); - const payload = parseEncodedPayload(payloadJson); + const createdAt = yield* encodePayloadTimestamp(event.payload.createdAt); + const updatedAt = yield* encodePayloadTimestamp(event.payload.updatedAt); yield* sql` INSERT INTO orchestration_v2_projection_messages ( message_id, @@ -2456,8 +2546,8 @@ export const layer: Layer.Layer = ${event.payload.nodeId}, ${event.payload.role}, ${booleanInt(event.payload.streaming)}, - ${stringField(payload, "createdAt")}, - ${stringField(payload, "updatedAt")}, + ${createdAt}, + ${updatedAt}, ${payloadJson} ) ON CONFLICT(message_id) @@ -2507,7 +2597,7 @@ export const layer: Layer.Layer = } case "turn-item.updated": { const payloadJson = yield* encodeTurnItemPayload(event.payload); - const payload = parseEncodedPayload(payloadJson); + const updatedAt = yield* encodePayloadTimestamp(event.payload.updatedAt); yield* sql` INSERT INTO orchestration_v2_projection_turn_items ( turn_item_id, @@ -2534,7 +2624,7 @@ export const layer: Layer.Layer = ${event.payload.ordinal}, ${event.payload.type}, ${event.payload.status}, - ${stringField(payload, "updatedAt")}, + ${updatedAt}, ${payloadJson} ) ON CONFLICT(turn_item_id) @@ -2988,21 +3078,6 @@ export const layer: Layer.Layer = ) AND request.type = 'run_interrupt_request' UNION - -- An open question or approval stays in the window: clients - -- build the answer panel from its row, and a run that kept - -- working pushes the row out of the newest turns. - SELECT question.rowid AS item_rowid, question.ordinal, question.turn_item_id - FROM orchestration_v2_projection_runtime_requests AS pending - JOIN orchestration_v2_projection_turn_items AS question - ON question.node_id = pending.node_id - WHERE pending.thread_id = ${threadId} - AND pending.status = 'pending' - AND question.thread_id = ${threadId} - AND question.type IN ('user_input_request', 'approval_request') - AND ${window.rowLimit} > 0 - AND ${window.anchorItemId ?? null} IS NULL - AND ${window.requiredRunId ?? null} IS NULL - UNION SELECT latest.item_rowid, latest.ordinal, latest.turn_item_id FROM ( SELECT rowid AS item_rowid, ordinal, turn_item_id @@ -3241,7 +3316,9 @@ export const layer: Layer.Layer = ] = yield* Effect.all([ decodeThreadPayload(threadRow.payload_json), (fields !== undefined && !fields.includes("runs")) || - (window === undefined && matchesNothing(filter?.runIds)) + (window === undefined && + (matchesNothing(filter?.runIds) || + (matchesNothing(filter?.runStatuses) && filter?.includeLatestRun !== true))) ? Effect.succeed([]) : window === undefined ? sql` @@ -3249,6 +3326,18 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_runs WHERE thread_id = ${threadId} ${filter?.runIds === undefined ? sql`` : sql`AND run_id IN (SELECT value FROM json_each(${encodeIdList(filter.runIds)}))`} + ${ + filter?.runStatuses === undefined + ? sql`` + : sql`AND (status IN (SELECT value FROM json_each(${encodeIdList(filter.runStatuses)})) + OR (${filter.includeLatestRun === true ? 1 : 0} = 1 AND run_id = ( + SELECT run_id FROM orchestration_v2_projection_runs + WHERE thread_id = ${threadId} + AND (status <> 'cancelled' OR json_extract(payload_json, '$.startedAt') IS NOT NULL) + ORDER BY ordinal DESC + LIMIT 1 + )))` + } ORDER BY ordinal ASC ` : sql` @@ -3386,13 +3475,14 @@ export const layer: Layer.Layer = OR threads.provider_thread_id IN (SELECT value FROM json_each(${cohortProviderThreadIds}))) ORDER BY sessions.updated_at ASC, sessions.provider_session_id ASC `, - fields !== undefined && !fields.includes("providerThreads") + (fields !== undefined && !fields.includes("providerThreads")) || + (window === undefined && matchesNothing(filter?.providerThreadIds)) ? Effect.succeed([]) : window === undefined ? sql` SELECT payload_json FROM orchestration_v2_projection_provider_threads - WHERE thread_id = ${threadId} + WHERE (thread_id = ${threadId} OR owner_node_id IN ( SELECT node_id FROM orchestration_v2_projection_nodes @@ -3403,7 +3493,8 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_subagents WHERE thread_id = ${threadId} AND provider_thread_id IS NOT NULL - ) + )) + ${filter?.providerThreadIds === undefined ? sql`` : sql`AND provider_thread_id IN (SELECT value FROM json_each(${encodeIdList(filter.providerThreadIds)}))`} ORDER BY COALESCE(first_run_ordinal, 0), provider_thread_id ASC ` : sql` @@ -3415,14 +3506,25 @@ export const layer: Layer.Layer = AND provider_thread_id = ${latestTodoList?.provider_thread_id ?? null}) ORDER BY COALESCE(first_run_ordinal, 0), provider_thread_id ASC `, - fields !== undefined && !fields.includes("providerTurns") + (fields !== undefined && !fields.includes("providerTurns")) || + (window === undefined && + matchesNothing(filter?.providerTurnAttemptIds, filter?.providerTurnThreadIds)) ? Effect.succeed([]) : window === undefined ? sql` SELECT payload_json FROM orchestration_v2_projection_provider_turns WHERE thread_id = ${threadId} - ORDER BY provider_thread_id ASC, ordinal ASC + ${filter?.providerTurnAttemptIds === undefined ? sql`` : sql`AND run_attempt_id IN (SELECT value FROM json_each(${encodeIdList(filter.providerTurnAttemptIds)}))`} + ${filter?.providerTurnThreadIds === undefined ? sql`` : sql`AND provider_thread_id IN (SELECT value FROM json_each(${encodeIdList(filter.providerTurnThreadIds)}))`} + ${ + filter?.latestProviderTurnUsageOnly === true + ? sql`AND json_extract(payload_json, '$.tokenUsage') IS NOT NULL + ORDER BY julianday(json_extract(payload_json, '$.tokenUsage.updatedAt')) DESC, + provider_thread_id ASC, ordinal ASC + LIMIT 1` + : sql`ORDER BY provider_thread_id ASC, ordinal ASC` + } ` : sql` SELECT payload_json FROM orchestration_v2_projection_provider_turns @@ -3432,13 +3534,15 @@ export const layer: Layer.Layer = OR node_id IN (SELECT value FROM json_each(${cohortNodeIds}))) ORDER BY provider_thread_id ASC, ordinal ASC `, - fields !== undefined && !fields.includes("runtimeRequests") + (fields !== undefined && !fields.includes("runtimeRequests")) || + (window === undefined && matchesNothing(filter?.runtimeRequestStatuses)) ? Effect.succeed([]) : window === undefined ? sql` SELECT payload_json FROM orchestration_v2_projection_runtime_requests WHERE thread_id = ${threadId} + ${filter?.runtimeRequestStatuses === undefined ? sql`` : sql`AND status IN (SELECT value FROM json_each(${encodeIdList(filter.runtimeRequestStatuses)}))`} ORDER BY created_at ASC, runtime_request_id ASC ` : sql` @@ -3553,13 +3657,15 @@ export const layer: Layer.Layer = ) ORDER BY rowid ASC `, - fields !== undefined && !fields.includes("contextTransfers") + (fields !== undefined && !fields.includes("contextTransfers")) || + (window === undefined && matchesNothing(filter?.contextTransferStatuses)) ? Effect.succeed([]) : window === undefined ? sql` SELECT payload_json FROM orchestration_v2_projection_context_transfers - WHERE source_thread_id = ${threadId} OR target_thread_id = ${threadId} + WHERE (source_thread_id = ${threadId} OR target_thread_id = ${threadId}) + ${filter?.contextTransferStatuses === undefined ? sql`` : sql`AND status IN (SELECT value FROM json_each(${encodeIdList(filter.contextTransferStatuses)}))`} ORDER BY rowid ASC ` : sql` @@ -3606,10 +3712,19 @@ export const layer: Layer.Layer = ]); const messageOrdinals = new Map(); if (fields !== undefined && fields.includes("messages") && messages.length > 1) { + // Broad role reads keep the original query to avoid per-ID lookup overhead. + const loadedMessageIds = + filter?.messageIds === undefined && + filter?.messageRunIds === undefined && + (filter?.messageRoles === undefined || + (filter.messageRoles.includes("user") && filter.messageRoles.includes("assistant"))) + ? undefined + : messages.map((message) => message.id); const rows = yield* sql<{ message_id: string; ordinal: number }>` SELECT json_extract(payload_json, '$.messageId') AS message_id, MIN(ordinal) AS ordinal FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} AND type IN ('user_message', 'assistant_message') + ${loadedMessageIds === undefined ? sql`` : sql`AND json_extract(payload_json, '$.messageId') IN (SELECT value FROM json_each(${encodeIdList(loadedMessageIds)}))`} GROUP BY json_extract(payload_json, '$.messageId') `; for (const row of rows) messageOrdinals.set(row.message_id, row.ordinal); @@ -3918,6 +4033,13 @@ export const layer: Layer.Layer = ${booleanInt(options.autoResume)} OR (${booleanInt(options.snooze)} AND julianday(json_extract(item.payload_json, '$.failure.resetAt')) > julianday(${DateTime.formatIso(options.now)})) ) + -- A subagent of a settled parent is abandoned work; resuming it would wake the parent. + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_threads parent + WHERE json_extract(t.payload_json, '$.lineage.relationshipToParent') = 'subagent' + AND parent.thread_id = json_extract(t.payload_json, '$.lineage.parentThreadId') + AND json_extract(parent.payload_json, '$.settledOverride') = 'settled' + ) ) ) AND NOT EXISTS ( @@ -4012,6 +4134,7 @@ export const layer: Layer.Layer = json_extract(child.payload_json, '$.lineage.relationshipToParent') = 'subagent' AND json_extract(child.payload_json, '$.lineage.parentThreadId') IS NOT NULL AND json_extract(child.payload_json, '$.forkedFrom.type') = 'node' + ELSE 0 END AND (EXISTS ( SELECT 1 FROM orchestration_v2_projection_runs AS requested JOIN orchestration_v2_projection_subagents AS task @@ -4034,7 +4157,6 @@ export const layer: Layer.Layer = AND target_thread_id = json_extract(child.payload_json, '$.lineage.parentThreadId') AND type = 'subagent_result' ))) - ELSE 0 END `; case "runtime": return sql` @@ -4091,9 +4213,10 @@ export const layer: Layer.Layer = AND EXISTS ( SELECT 1 FROM orchestration_v2_projection_nodes AS node WHERE node.thread_id = subagents.child_thread_id - AND node.run_id IS NULL + -- Matches nodes_live_idx; +run_id keeps thread_run_idx from scanning settled nodes. + AND node.status IN ('pending', 'starting', 'running', 'waiting') + AND +node.run_id IS NULL AND node.kind = 'root_turn' - AND node.status IN ('pending', 'running', 'waiting') ) UNION -- No native subagent outlives the provider process that ran it. @@ -4412,7 +4535,15 @@ export const layer: Layer.Layer = ); const getThreadProjection: ProjectionStoreV2Shape["getThreadProjection"] = (threadId) => - readProjection(threadId, new Set()); + sql + .withTransaction(readProjection(threadId, new Set())) + .pipe( + Effect.mapError((cause) => + isProjectionStoreThreadNotFoundError(cause) || isProjectionStoreReadError(cause) + ? cause + : new ProjectionStoreReadError({ threadId, cause }), + ), + ); const getRuntimeRecoveryProjection: ProjectionStoreV2Shape["getRuntimeRecoveryProjection"] = Effect.fn("ProjectionStore.getRuntimeRecoveryProjection")( @@ -4585,18 +4716,20 @@ export const layer: Layer.Layer = sql` SELECT provider_thread.payload_json FROM orchestration_v2_projection_provider_threads AS provider_thread - WHERE ( - provider_thread.thread_id = ${threadId} - OR EXISTS ( - SELECT 1 FROM orchestration_v2_projection_nodes AS owner - WHERE owner.node_id = provider_thread.owner_node_id - AND owner.thread_id = ${threadId} - ) - OR EXISTS ( - SELECT 1 FROM orchestration_v2_projection_subagents AS subagent - WHERE subagent.provider_thread_id = provider_thread.provider_thread_id - AND subagent.thread_id = ${threadId} - ) + WHERE provider_thread.provider_thread_id IN ( + SELECT provider_thread_id FROM orchestration_v2_projection_provider_threads + WHERE thread_id = ${threadId} + UNION + -- Start with non-null owners; a thread can have a large settled node history. + SELECT owned.provider_thread_id + FROM orchestration_v2_projection_provider_threads AS owned + INDEXED BY orchestration_v2_projection_provider_threads_owner_idx + CROSS JOIN orchestration_v2_projection_nodes AS owner + ON owner.node_id = owned.owner_node_id + WHERE owned.owner_node_id IS NOT NULL AND owner.thread_id = ${threadId} + UNION + SELECT provider_thread_id FROM orchestration_v2_projection_subagents + WHERE thread_id = ${threadId} AND provider_thread_id IS NOT NULL ) AND ( provider_thread.status = 'active' @@ -4659,14 +4792,14 @@ export const layer: Layer.Layer = WHERE thread_id = ${threadId} AND status = 'pending' ORDER BY created_at ASC, runtime_request_id ASC `, + // CROSS JOIN keeps empty run cohorts out of message history; the run index bounds each lookup. sql` - SELECT message.payload_json FROM orchestration_v2_projection_messages AS message - WHERE message.thread_id = ${threadId} AND message.streaming = 1 - AND message.run_id IN ( - SELECT run_id FROM orchestration_v2_projection_runs - WHERE thread_id = ${threadId} - AND status IN ('queued', 'preparing', 'starting', 'running', 'waiting') - ) + SELECT message.payload_json FROM orchestration_v2_projection_runs AS run + CROSS JOIN orchestration_v2_projection_messages AS message + INDEXED BY orchestration_v2_projection_messages_run_idx ON message.run_id = run.run_id + WHERE run.thread_id = ${threadId} + AND run.status IN ('queued', 'preparing', 'starting', 'running', 'waiting') + AND message.thread_id = ${threadId} AND message.streaming = 1 ORDER BY message.created_at ASC, message.message_id ASC `, sql` @@ -5342,6 +5475,16 @@ export const layer: Layer.Layer = Effect.map((rows) => rows[0]?.count ?? 0), Effect.mapError(controlReadError(threadId)), ); + const listAppOwnedChildThreadIds: ProjectionStoreV2Shape["listAppOwnedChildThreadIds"] = ( + threadId, + ) => + sql<{ readonly child_thread_id: string }>`SELECT child_thread_id + FROM orchestration_v2_projection_subagents + WHERE thread_id = ${threadId} AND origin = 'app_owned' AND child_thread_id IS NOT NULL + ORDER BY COALESCE(started_at, ''), subagent_id ASC`.pipe( + Effect.map((rows) => [...new Set(rows.map((row) => ThreadId.make(row.child_thread_id)))]), + Effect.mapError(controlReadError(threadId)), + ); const getNextTurnItemOrdinal: ProjectionStoreV2Shape["getNextTurnItemOrdinal"] = (threadId) => sql<{ ordinal: number | null }>`SELECT MAX(ordinal) AS ordinal FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId}`.pipe( @@ -5374,7 +5517,8 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} AND type = 'dynamic_tool' - AND (payload_json LIKE '%htmlRender%' OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`}) + AND (payload_json LIKE '%htmlRender%' OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} + OR payload_json LIKE '%preview_recording_stop%') `, sql<{ id: string }>` SELECT DISTINCT json_extract(attachment.value, '$.id') AS id @@ -5414,6 +5558,23 @@ export const layer: Layer.Layer = Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause })), ); + const getMessagesById = Effect.fn("ProjectionStore.getMessagesById")( + function* (threadId: ThreadId, messageIds: ReadonlyArray) { + if (messageIds.length === 0) + return new Map(); + const rows = yield* sql` + SELECT message.payload_json + FROM json_each(${encodeIdList([...new Set(messageIds)])}) AS requested + CROSS JOIN orchestration_v2_projection_messages AS message + ON message.message_id = requested.value + WHERE message.thread_id = ${threadId} + `; + const messages = yield* decodeRows(decodeMessagePayload, threadId)(rows); + return new Map(messages.map((message) => [message.id, message])); + }, + (effect, threadId) => effect.pipe(Effect.mapError(controlReadError(threadId))), + ); + const getThreadRecords: ProjectionStoreV2Shape["getThreadRecords"] = ( threadId, fields, @@ -5755,6 +5916,20 @@ export const layer: Layer.Layer = .withTransaction( Effect.gen(function* () { const index = yield* readTimelineIndex(threadId, new Set()); + const attachmentOwners = + options.attachmentId === undefined + ? undefined + : new Set( + (yield* sql<{ thread_id: string; turn_item_id: string }>` + SELECT item.thread_id, item.turn_item_id + FROM orchestration_v2_projection_turn_items AS item, + json_each(item.payload_json, '$.attachments') AS attachment + WHERE item.thread_id IN ${sql.in([...new Set(index.visible.map((row) => row.sourceThreadId))])} + AND item.type = 'user_message' + AND json_extract(attachment.value, '$.id') = ${options.attachmentId}`).map( + (row) => `${row.thread_id}:${row.turn_item_id}`, + ), + ); const matching = index.visible .map((row, position) => ({ ...row, position })) .filter((row) => @@ -5765,6 +5940,9 @@ export const layer: Layer.Layer = row.item.type, )) : row.sourceItemId === options.itemId, + ) + .filter( + (row) => attachmentOwners?.has(`${row.sourceThreadId}:${row.sourceItemId}`) ?? true, ); const page = matching.slice(0, options.limit); const sources = yield* encodeTimelineSources( @@ -5861,6 +6039,33 @@ export const layer: Layer.Layer = : {}), ...(historyAnchor === undefined ? {} : { historyAnchor }), }); + // Pending requests outlive their turn's window; clients read their prompt from the card. + const retainedIds = new Set(projection.turnItems.map((item) => item.id)); + const pendingCards = (yield* decodeRows( + decodeTurnItemPayload, + threadId, + )( + yield* sql` + SELECT item.payload_json + FROM orchestration_v2_projection_runtime_requests AS request + CROSS JOIN orchestration_v2_projection_turn_items AS item + ON item.node_id = request.node_id + AND item.type IN ('approval_request', 'user_input_request') + AND json_extract(item.payload_json, '$.requestId') = request.runtime_request_id + WHERE request.thread_id = ${threadId} AND request.status = 'pending' + AND item.thread_id = ${threadId} + `, + )).filter((item) => !retainedIds.has(item.id)); + const windowProjection = + pendingCards.length === 0 + ? projection + : { + ...projection, + turnItems: [...projection.turnItems, ...pendingCards].toSorted( + (left, right) => + left.ordinal - right.ordinal || left.id.localeCompare(right.id), + ), + }; const rows = yield* sql<{ readonly snapshot_sequence: number | null }>` SELECT MAX(sequence) AS snapshot_sequence FROM orchestration_events @@ -5871,7 +6076,7 @@ export const layer: Layer.Layer = return { schemaVersion: ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION, snapshotSequence: rows[0]?.snapshot_sequence ?? 0, - projection, + projection: windowProjection, }; }), ) @@ -5929,7 +6134,8 @@ export const layer: Layer.Layer = familyOf, worktreePath, threadIds, - }: ShellSnapshotOptions = {}, + listedOnly = false, + }: ShellSnapshotOptions & { readonly listedOnly?: boolean } = {}, ) => sql` SELECT @@ -6111,7 +6317,12 @@ export const layer: Layer.Layer = candidate.ordinal DESC, candidate.run_id DESC LIMIT 1 ) AND blocked.status = 'failed' - WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`}${ + WHERE t.deleted_at IS NULL${ + listedOnly + ? sql` AND json_extract(t.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(t.payload_json, '$.lineage.sideChat') IS NOT 1` + : sql`` + }${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`}${ projectId === undefined ? sql`` : sql` AND t.project_id = ${projectId}` }${ location === "active" @@ -6349,6 +6560,16 @@ export const layer: Layer.Layer = INDEXED BY orchestration_v2_projection_turn_items_waiting_link_idx WHERE link.thread_id = t.thread_id AND link.type = 'link_request' AND link.status = 'waiting' + -- linkCardStillAsks: once the user writes after the card, it stops asking. + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_messages message + WHERE message.thread_id = t.thread_id AND message.role = 'user' + AND json_extract(message.payload_json, '$.createdBy') = 'user' + AND NOT ${isIdleCompactionMessage("message")} + AND message.updated_at > COALESCE( + json_extract(link.payload_json, '$.startedAt'), link.updated_at + ) + ) ) ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -6797,6 +7018,109 @@ export const layer: Layer.Layer = Effect.map(decodeShellSnapshot), ); + const getPullRequestSnapshot: ProjectionStoreV2Shape["getPullRequestSnapshot"] = Effect.fn( + "ProjectionStore.getPullRequestSnapshot", + )(function* ({ threadId, unsettledOnly = false }) { + const { rows, sequenceRows } = yield* sql.withTransaction( + Effect.gen(function* () { + const rows = yield* sql` + SELECT t.payload_json FROM orchestration_v2_projection_threads t + WHERE t.deleted_at IS NULL${ + threadId === undefined + ? sql` AND json_extract(t.payload_json, '$.archivedAt') IS NULL` + : sql` AND t.thread_id = ${threadId}` + }${ + unsettledOnly + ? sql` AND json_extract(t.payload_json, '$.settledAt') IS NULL + AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled'` + : sql`` + } + ORDER BY t.updated_at ASC, t.thread_id ASC + `; + const sequenceRows = yield* sql<{ readonly snapshot_sequence: number | null }>` + SELECT MAX(sequence) AS snapshot_sequence FROM orchestration_events + WHERE application_event_version = 2 AND aggregate_kind = 'thread' + `; + return { rows, sequenceRows }; + }), + ); + // Decode after releasing the reader, keeping rows and sequence from the same transaction. + const threads = yield* Effect.forEach(rows, (row) => + decodePullRequestThread(row.payload_json).pipe( + Effect.map((thread) => ({ ...thread, pullRequests: threadPullRequestsOf(thread) })), + ), + ); + return { snapshotSequence: sequenceRows[0]?.snapshot_sequence ?? 0, threads }; + }, Effect.mapError(shellSnapshotReadError)); + + const getAgentAwarenessSnapshot = Effect.fn("ProjectionStore.getAgentAwarenessSnapshot")( + function* (threadId?: ThreadId) { + const { threadRows, providerThreadRows, pendingTurnItemRows, sequenceRows } = + yield* sql.withTransaction( + Effect.gen(function* () { + const threadRows = yield* selectShellThreadRows( + threadId, + threadId === undefined ? { location: "active", listedOnly: true } : {}, + ); + const ids = threadRows.map((row) => ThreadId.make(row.thread_id)); + const providerThreadRows = + ids.length === 0 ? [] : yield* selectShellProviderThreadRows(ids); + const pendingTurnItemRows = + ids.length === 0 + ? [] + : yield* selectShellPendingTurnItemRows( + ids, + threadRows.flatMap((row) => + row.active_run_id !== null && row.active_run_status === "running" + ? [row.active_run_id] + : [], + ), + ); + const sequenceRows = yield* sql<{ readonly snapshot_sequence: number | null }>` + SELECT MAX(sequence) AS snapshot_sequence FROM orchestration_events + WHERE application_event_version = 2 AND aggregate_kind = 'thread' + `; + return { threadRows, providerThreadRows, pendingTurnItemRows, sequenceRows }; + }), + ); + const { providerThreadsByThreadId, pendingTurnItemsByThreadId } = + yield* pendingBackgroundDataByThreadId({ providerThreadRows, pendingTurnItemRows }); + const threads = yield* Effect.forEach(threadRows, (row) => + shellThreadStateFromRow({ + row, + // Awareness never consumes timeline counts or inherited fork history. + runOrdinalsByThreadId: new Map(), + itemCountsByThreadId: new Map(), + shellItemCountsByThreadId: new Map(), + providerThreadsByThreadId, + pendingTurnItemsByThreadId, + }).pipe( + Effect.map((state): AgentAwarenessThread => ({ + id: state.thread.id, + projectId: state.thread.projectId, + archivedAt: state.thread.archivedAt, + activeRunId: state.activeRunId, + latestRunId: state.latestRunId, + hasActionableProposedPlan: state.hasActionableProposedPlan, + title: state.thread.title, + lineage: state.thread.lineage, + modelSelection: state.thread.modelSelection, + settledOverride: state.thread.settledOverride, + updatedAt: state.updatedAt, + status: state.latestRunStatus, + activityRunStatus: state.activityRunStatus, + latestRunCompletedAt: state.latestRunCompletedAt, + latestRunStopRequested: state.latestRunStopRequested, + pendingRuntimeRequest: state.pendingRuntimeRequest, + pendingBackgroundTasks: state.pendingBackgroundTasks, + })), + ), + ); + return { snapshotSequence: sequenceRows[0]?.snapshot_sequence ?? 0, threads }; + }, + Effect.mapError(shellSnapshotReadError), + ); + const getShellSnapshot: ProjectionStoreV2Shape["getShellSnapshot"] = (options) => sql .withTransaction(readShellSnapshotRows(options)) @@ -6911,6 +7235,18 @@ export const layer: Layer.Layer = })); }, Effect.mapError(shellSnapshotReadError)); + const getThreadTitles = Effect.fn("ProjectionStore.getThreadTitles")(function* ( + threadIds: ReadonlyArray, + ) { + if (threadIds.length === 0) return {}; + const rows = yield* sql<{ readonly thread_id: ThreadId; readonly title: string }>` + SELECT thread_id, title + FROM orchestration_v2_projection_threads + WHERE deleted_at IS NULL AND thread_id IN ${sql.in(threadIds)} + `; + return Object.fromEntries(rows.map((row) => [row.thread_id, row.title])); + }, Effect.mapError(shellSnapshotReadError)); + const findGuestShareThreadId = Effect.fn("ProjectionStore.findGuestShareThreadId")(function* ( shareId: GuestShareId, ) { @@ -7064,7 +7400,9 @@ export const layer: Layer.Layer = return { apply, + getAgentAwarenessSnapshot: readOnReader(getAgentAwarenessSnapshot), getShellSnapshot: readOnReader(getShellSnapshot), + getPullRequestSnapshot: readOnReader(getPullRequestSnapshot), listActiveSubagentThreadIds: readOnReader(listActiveSubagentThreadIds), countThreads: readOnReader(countThreads), listActiveShellIndex: readOnReader(listActiveShellIndex), @@ -7075,9 +7413,10 @@ export const layer: Layer.Layer = getThread: readOnReader(getThread), getSettlementCandidates: readOnReader(getSettlementCandidates), getThreadsWithPullRequests: readOnReader(getThreadsWithPullRequests), + getThreadTitles: readOnReader(getThreadTitles), getThreadWorktreePaths: readOnReader(getThreadWorktreePaths), getOpenRemoteDelegatedTasks: getOpenRemoteDelegatedTasks.pipe(NodeSqliteClient.readOnly), - getThreadProjection, + getThreadProjection: readOnReader(getThreadProjection), getTurnStartContext: readOnReader(getTurnStartContext), getTurnStartHistory: readOnReader(getTurnStartHistory), getRuntimeRecoveryProjection: readOnReader(getRuntimeRecoveryProjection), @@ -7095,8 +7434,10 @@ export const layer: Layer.Layer = isNodeStored: readOnReader(isNodeStored), getMessageCount: readOnReader(getMessageCount), getSubagentCount: readOnReader(getSubagentCount), + listAppOwnedChildThreadIds: readOnReader(listAppOwnedChildThreadIds), getNextTurnItemOrdinal: readOnReader(getNextTurnItemOrdinal), getTurnItem: readOnReader(getTurnItem), + getMessagesById: readOnReader(getMessagesById), getThreadRecords: (threadId, fields, filter) => getThreadRecords(threadId, fields, filter).pipe(NodeSqliteClient.readOnly), getRuntimeRequest: readOnReader(getRuntimeRequest), diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 927a9060dd4f4..d8abea76d1ed6 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -1,4 +1,5 @@ import { assert, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CheckpointId, @@ -69,12 +70,11 @@ const layerTestEventSink = EventSink.layer.pipe( const layerGit = GitVcsDriver.layer.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "ingestor-git-" })), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const layerEmptyInstances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ - // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. - getInstance: () => Effect.succeed(undefined), + getInstance: () => Effect.undefined, }); const makeLayerTest = ( ingestorEventSink: Layer.Layer, diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index 2fd9b13a35c33..28f1df58a6b1f 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -501,7 +501,19 @@ it.effect("recovery closes a secret card's form but leaves a link card open", () }).pipe(Effect.provide(layer)); }); -it.effect.each(["queued", "preparing"] as const)("recovers $0 work after restart", (status) => { +it.effect.each([ + "queued", + "preparing", + "starting", + "starting-other-request", + "starting-own-request", + "starting-scheduled", +] as const)("recovers $0 work after restart", (scenario) => { + const status = scenario.startsWith("starting") ? "starting" : scenario; + const terminalized = + status === "preparing" || + scenario === "starting-own-request" || + scenario === "starting-scheduled"; const threadId = ThreadId.make("thread_queued_restart"); const runId = RunId.make("run_queued_restart"); const attemptId = RunAttemptId.make("attempt_queued_restart"); @@ -510,7 +522,18 @@ it.effect.each(["queued", "preparing"] as const)("recovers $0 work after restart null; const projection = { thread: { id: threadId }, - runtimeRequests: [], + runtimeRequests: scenario.endsWith("request") + ? [ + { + id: RuntimeRequestId.make("pending-recovery-request"), + nodeId: + scenario === "starting-own-request" ? rootNodeId : NodeId.make("other-run-request"), + kind: "user_input", + status: "pending", + responseCapability: { type: "message" }, + }, + ] + : [], providerSessions: [], providerThreads: [], providerTurns: [], @@ -518,6 +541,9 @@ it.effect.each(["queued", "preparing"] as const)("recovers $0 work after restart { id: runId, status, + startedAt: null, + activeAttemptId: attemptId, + userMessageId: MessageId.make("message_queued_restart"), queuePosition: 1, providerInstanceId: ProviderInstanceId.make("codex"), ...(status === "preparing" @@ -540,6 +566,15 @@ it.effect.each(["queued", "preparing"] as const)("recovers $0 work after restart }, ], nodes: [ + ...(scenario === "starting-other-request" + ? [ + { + id: NodeId.make("other-run-request"), + runId: RunId.make("earlier-run"), + status: "waiting", + }, + ] + : []), { id: rootNodeId, runId, @@ -547,7 +582,16 @@ it.effect.each(["queued", "preparing"] as const)("recovers $0 work after restart }, ], subagents: [], - messages: [], + messages: + status === "starting" + ? [ + { + id: MessageId.make("message_queued_restart"), + createdBy: "user", + ...(scenario === "starting-scheduled" ? { scheduledTaskId: "scheduled-task" } : {}), + }, + ] + : [], turnItems: [], } as unknown as OrchestrationV2ThreadProjection; const layer = ProviderRuntimeRecovery.layer.pipe( @@ -557,6 +601,16 @@ it.effect.each(["queued", "preparing"] as const)("recovers $0 work after restart Layer.mock(ProjectionStore.ProjectionStoreV2)({ getRecoveryThreadIds: () => Effect.succeed([threadId]), getRuntimeRecoveryProjection: () => Effect.succeed(projection), + getMessagesById: (requestedThreadId, messageIds) => + Effect.succeed( + new Map( + projection.messages + .filter( + (message) => requestedThreadId === threadId && messageIds.includes(message.id), + ) + .map((message) => [message.id, message]), + ), + ), getThreadRecords: () => Effect.succeed(projection), }), Layer.mock(EventSink.EventSinkV2)({ @@ -579,7 +633,7 @@ it.effect.each(["queued", "preparing"] as const)("recovers $0 work after restart return Effect.gen(function* () { const summary = yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("startup"); - assert.equal(summary.terminalizedRuns, status === "preparing" ? 1 : 0); + assert.equal(summary.terminalizedRuns, terminalized ? 1 : 0); const command = committedInput; assert.isNotNull(command); if (command === null) return; @@ -588,17 +642,17 @@ it.effect.each(["queued", "preparing"] as const)("recovers $0 work after restart const nodeEvent = command.events.find((event) => event.type === "node.updated"); assert.equal( runEvent?.type === "run.updated" ? runEvent.payload.status : null, - status === "preparing" ? "cancelled" : "queued", + terminalized ? "cancelled" : "queued", ); assert.isEmpty(command.effects); - if (status === "preparing") { + if (terminalized) { assert.equal( attemptEvent?.type === "run-attempt.updated" ? attemptEvent.payload.status : null, "cancelled", ); assert.equal( nodeEvent?.type === "node.updated" ? nodeEvent.payload.status : null, - "cancelled", + scenario === "starting-own-request" ? null : "cancelled", ); } else { assert.equal(runEvent?.type === "run.updated" ? runEvent.payload.queuePosition : null, 1); @@ -674,6 +728,14 @@ it.effect( nodeId: rootNodeId, status: "running", }, + ...(["assistant_message", "reasoning", "proposed_plan"] as const).map((type) => ({ + id: TurnItemId.make(`turn_item_recovery_cancel_${type}`), + runId, + nodeId: rootNodeId, + type, + status: "running", + streaming: true, + })), ], } as unknown as OrchestrationV2ThreadProjection; const layer = ProviderRuntimeRecovery.layer.pipe( @@ -727,12 +789,27 @@ it.effect( ["provider-turn.updated", "cancelled"], ["message.updated", null], ["turn-item.updated", "cancelled"], + ["turn-item.updated", "cancelled"], + ["turn-item.updated", "cancelled"], + ["turn-item.updated", "cancelled"], ["provider-thread.updated", "idle"], ["provider-session.updated", "stopped"], ], ); const messageEvent = events.find((event) => event.type === "message.updated"); assert.isFalse(messageEvent?.type === "message.updated" && messageEvent.payload.streaming); + assert.deepEqual( + events.flatMap((event) => + event.type === "turn-item.updated" && "streaming" in event.payload + ? [[event.payload.type, event.payload.streaming]] + : [], + ), + [ + ["assistant_message", false], + ["reasoning", false], + ["proposed_plan", false], + ], + ); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index c344357972268..5abaab7f1228f 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -25,6 +25,7 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import * as ServerSettings from "../serverSettings.ts"; import { restartContinuationRun } from "./RestartContinuation.ts"; import { isBufferedProviderNotification } from "./NotificationMailbox.ts"; +import { queuedRunsInDeliveryOrder } from "./QueuedRunOrder.ts"; import { endOrphanedNativeSubagent, endRunlessRootTurns, @@ -154,7 +155,13 @@ const cancelledItem = ( ): OrchestrationV2ThreadProjection["turnItems"][number] => item.type === "secret_request" && item.secretStatus === "pending" ? { ...item, status: "cancelled", secretStatus: "cancelled", completedAt: now, updatedAt: now } - : { ...item, status: "cancelled", completedAt: now, updatedAt: now }; + : { + ...item, + ...("streaming" in item ? { streaming: false } : {}), + status: "cancelled", + completedAt: now, + updatedAt: now, + }; function latestStartedRun( projection: ProjectionStore.ProjectionRuntimeRecoveryState, @@ -204,7 +211,7 @@ export const make = Effect.gen(function* () { stoppedThisPass: ReadonlyMap = new Map(), ) { const now = yield* DateTime.now; - const runs = [] as Array; + let runs = [] as Array; for (const run of nonterminalRuns(projection)) { if (run.status === "waiting") { const checkpointEffects = yield* outbox @@ -319,13 +326,65 @@ export const make = Effect.gen(function* () { cancelledBackgroundNativeIds.add(item.nativeItemRef.nativeId); } }; + const unstartedRuns = runs.filter( + (run) => + run.status === "starting" && + neverStarted(run) && + !projection.providerTurns.some((turn) => turn.runAttemptId === run.activeAttemptId) && + !projection.runtimeRequests.some( + (request) => + request.status === "pending" && + projection.nodes.some((node) => node.id === request.nodeId && node.runId === run.id), + ), + ); const queuedRuns = projection.runs.filter((run) => run.status === "queued"); + const pendingRuns = [...unstartedRuns, ...queuedRuns]; const queuedMessages = - queuedRuns.length === 0 + pendingRuns.length === 0 + ? [] + : [ + ...(yield* projections + .getMessagesById( + projection.thread.id, + pendingRuns.map((run) => run.userMessageId), + ) + .pipe( + Effect.mapError( + (cause) => + new ProviderRuntimeRecoveryError({ + operation: "read-projections", + threadId: projection.thread.id, + cause, + }), + ), + )).values(), + ]; + const bufferedMessageIds = new Set( + queuedMessages.filter(isBufferedProviderNotification).map((message) => message.id), + ); + const retryableMessageIds = new Set( + queuedMessages + .filter( + (message) => + message.createdBy === "user" && + message.scheduledTaskId === undefined && + message.notification === undefined && + message.delegatedCompletion === undefined, + ) + .map((message) => message.id), + ); + // A saved user message the provider never accepted stays available through the held queue. + const retryableRuns = unstartedRuns.filter((run) => + retryableMessageIds.has(run.userMessageId), + ); + const retryableRunIds = new Set(retryableRuns.map((run) => run.id)); + const retryableItems = + retryableRuns.length === 0 ? [] : (yield* projections - .getThreadRecords(projection.thread.id, ["messages"], { - messageIds: queuedRuns.map((run) => run.userMessageId), + .getThreadRecords(projection.thread.id, ["turnItems"], { + turnItemTypes: ["user_message"], + turnItemRunIds: retryableRuns.map((run) => run.id), }) .pipe( Effect.mapError( @@ -336,16 +395,36 @@ export const make = Effect.gen(function* () { cause, }), ), - )).messages; - const bufferedMessageIds = new Set( - queuedMessages.filter(isBufferedProviderNotification).map((message) => message.id), - ); - for (const run of queuedRuns) { + )).turnItems; + for (const item of retryableItems) { + if (item.type !== "user_message" || !retryableMessageIds.has(item.messageId)) continue; + events.push({ + id: yield* allocateEventId(), + type: "turn-item.updated", + threadId: projection.thread.id, + ...(item.runId === null ? {} : { runId: item.runId }), + occurredAt: now, + payload: { + ...item, + inputIntent: "queued_turn", + status: "pending", + startedAt: null, + completedAt: null, + updatedAt: now, + }, + }); + } + runs = runs.filter((run) => !retryableRunIds.has(run.id)); + const heldRuns = [ + ...retryableRuns, + ...queuedRunsInDeliveryOrder({ runs: queuedRuns, messages: queuedMessages }), + ]; + for (const [index, run] of heldRuns.entries()) { if (bufferedMessageIds.has(run.userMessageId)) { runs.push(run); continue; } - if (run.queueHeld === true) continue; + if (run.queueHeld === true && retryableRuns.length === 0) continue; events.push({ id: yield* allocateEventId(), type: "run.updated", @@ -353,7 +432,12 @@ export const make = Effect.gen(function* () { runId: run.id, providerInstanceId: run.providerInstanceId, occurredAt: now, - payload: { ...run, queueHeld: true }, + payload: { + ...run, + status: "queued", + queueHeld: true, + ...(retryableRuns.length === 0 ? {} : { queuePosition: index + 1 }), + }, }); } for (const question of questions) { diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 73b2a81e95869..db5ce018512dc 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -1,4 +1,4 @@ -import * as NodeFS from "node:fs/promises"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -313,6 +313,102 @@ function makeProviderThread(input: { }; } +// A Codex native subagent's provider thread is owned by its child app thread +// but reachable from the parent's projection through the subagent record. +const makeNativeChildFixture = Effect.fnUntraced(function* () { + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const eventSink = yield* EventSink.EventSinkV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const parentThreadId = ThreadId.make("native-parent"); + const childThreadId = ThreadId.make("native-child"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + const childThreadEvent = yield* makeThreadCreatedEvent({ + idAllocator, + threadId: childThreadId, + now, + }); + const childProviderThread = makeProviderThread({ + idAllocator, + threadId: childThreadId, + providerSessionId, + nativeThreadId: "native-child-thread", + now, + }); + const parentRunId = idAllocator.derive.run({ threadId: parentThreadId, ordinal: 1 }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: parentThreadId, now }), + { + ...childThreadEvent, + payload: { + ...childThreadEvent.payload, + createdBy: "agent", + creationSource: "provider", + activeProviderThreadId: null, + lineage: { + parentThreadId, + relationshipToParent: "subagent", + rootThreadId: parentThreadId, + }, + }, + }, + { + id: yield* idAllocator.allocate.event({ threadId: childThreadId }), + type: "provider-thread.updated", + threadId: childThreadId, + occurredAt: now, + payload: childProviderThread, + }, + { + id: yield* idAllocator.allocate.event({ threadId: parentThreadId }), + type: "subagent.updated", + threadId: parentThreadId, + occurredAt: now, + payload: { + id: idAllocator.derive.nodeFromProviderItem({ + driver: CODEX_DRIVER, + nativeItemId: "native-child-task", + }), + threadId: parentThreadId, + runId: parentRunId, + parentNodeId: idAllocator.derive.rootNode({ runId: parentRunId }), + origin: "provider_native", + createdBy: "agent", + driver: CODEX_DRIVER, + providerInstanceId: modelSelection.instanceId, + providerThreadId: childProviderThread.id, + childThreadId, + nativeTaskRef: { + driver: CODEX_DRIVER, + nativeId: "native-child-task", + strength: "strong", + }, + prompt: "Reply READY", + title: "Native child", + model: null, + status: "completed", + result: "READY", + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }, + ], + }); + const parent = { threadId: parentThreadId, providerSessionId }; + const runtime = yield* manager.open({ ...parent, modelSelection, runtimePolicy }); + const resumeChild = runtime.resumeThread({ + providerThread: childProviderThread, + threadId: childThreadId, + modelSelection, + runtimePolicy, + }); + return { manager, parent, resumeChild }; +}); + function unimplemented(detail: string) { return Effect.fail( new ProviderAdapter.ProviderAdapterProtocolError({ @@ -539,8 +635,7 @@ function layerTest(input: { Layer.provide( Layer.mergeAll( Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ - // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. - getInstance: () => Effect.succeed(undefined), + getInstance: () => Effect.undefined, }), layerConfiguredEventSink, IdAllocator.layer, @@ -578,7 +673,7 @@ function layerTest(input: { ), ), ), - ).pipe(Layer.provide(NodeServices.layer)); + ).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); } // Coder: credentials are workspace file bridges, so the registry needs no HTTP server. @@ -608,7 +703,7 @@ const layerTestMcpRegistry = Layer.effect( ), ).pipe(Layer.provide(T3ToolDispatch.layer)), ), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const layerPausingMcpRegistry = (pause: { @@ -1721,7 +1816,12 @@ it.effect( assert.equal(captured?.providerInstanceId, modelSelection.instanceId); // Coder: the credential is a workspace file bridge with its own tool command. assert.match(captured?.toolCommand ?? "", /t3\.mjs'?$/); - yield* Effect.promise(() => NodeFS.access(captured!.endpoint)); + const bridgeExists = FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => fileSystem.exists(captured!.endpoint)), + Effect.provide(NodeServices.layer), + Effect.orDie, + ); + assert.isTrue(yield* bridgeExists); const token = captured?.authorizationHeader.replace(/^Bearer\s+/, ""); assert.isDefined(token); const resolved = yield* registry.resolve(token!); @@ -1735,12 +1835,7 @@ it.effect( assert.isUndefined(yield* mcpSessions.read(threadId)); assert.isUndefined(yield* registry.resolve(token!)); // Coder: revoking the credential removes its bridge. - yield* Effect.promise(() => - NodeFS.access(captured!.endpoint).then( - () => assert.fail("bridge directory still exists"), - () => undefined, - ), - ); + assert.isFalse(yield* bridgeExists, "bridge directory still exists"); }); yield* effect.pipe( @@ -3553,6 +3648,40 @@ it.effect( }), ); +it.effect( + "ProviderSessionManagerV2 keeps an attached native child loaded when its parent detaches", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + yield* Effect.gen(function* () { + const { manager, parent, resumeChild } = yield* makeNativeChildFixture(); + yield* resumeChild; + yield* manager.detach(parent); + + assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, []); + // The child's next turn trusts the manager's loaded cache, so the + // native thread must still be loaded for that turn to be heard. + yield* resumeChild; + assert.equal((yield* Ref.get(state)).resumeCount, 1); + assert.isTrue(yield* manager.isLive(parent.providerSessionId)); + }).pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 })), Effect.scoped); + }), +); + +it.effect( + "ProviderSessionManagerV2 unloads an unattached native child when its parent detaches", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + yield* Effect.gen(function* () { + const { manager, parent } = yield* makeNativeChildFixture(); + yield* manager.detach(parent); + + assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, ["native-child-thread"]); + }).pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 })), Effect.scoped); + }), +); + it.effect( "ProviderSessionManagerV2 re-attaching a thread waits for its in-flight unload, then reloads it", () => @@ -3754,7 +3883,7 @@ it.effect.each(["missing", "file"] as const)( [], ); }).pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 60_000 }))); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -3804,7 +3933,7 @@ it.effect( before.providerSessions, ); }).pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 60_000 }))); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 66b2151d27e4f..8441710eb602c 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1011,6 +1011,8 @@ export const layerWithOptions = ( ([entry, releasedAt]) => Option.match(entry, { onNone: () => Effect.void, + // Coder: upstream's `withMetrics` wrapper is not carried; the body stays upstream's. + // @effect-diagnostics-next-line effectFnOpportunity:off onSome: (entry) => Effect.gen(function* () { const releasedBackgroundTasks = yield* readReleasedBackgroundTasks({ @@ -2474,38 +2476,38 @@ export const layerWithOptions = ( } const unloadThread = detached.value.exposedRuntime.unloadThread; if (detached.value.supportsMultipleProviderThreads && unloadThread !== undefined) { - // Serialize unload with attachment so attached threads stay loaded and later attaches resume after unload. - yield* threadAttachment.withLock( - threadAttachmentKey(input), - Effect.gen(function* () { - const entry = (yield* Ref.get(sessions)).get(key); - if ( - entry?.runtime !== detached.value.runtime || - entry.attachedThreadIds.has(input.threadId) - ) { - return; - } - yield* Effect.forEach( - detachedProviderThreads.filter((thread) => thread.nativeThreadRef !== null), - (providerThread) => - unloadThread({ providerThread }).pipe( - Effect.timeout(UNLOAD_THREAD_TIMEOUT_MS), - Effect.catchCause((cause) => - Effect.logWarning( - "orchestration-v2.driver-session.detach-unload-failed", - { - providerSessionId: input.providerSessionId, - threadId: input.threadId, - providerThreadId: providerThread.id, - cause, - }, - ), - ), + // The projection includes native children owned by other app threads; + // lock each owner so an attached child stays loaded and later attaches wait. + for (const providerThread of detachedProviderThreads) { + if (providerThread.nativeThreadRef === null) continue; + const ownerThreadId = providerThread.appThreadId ?? input.threadId; + yield* threadAttachment.withLock( + threadAttachmentKey({ + providerSessionId: input.providerSessionId, + threadId: ownerThreadId, + }), + Effect.gen(function* () { + const entry = (yield* Ref.get(sessions)).get(key); + if ( + entry?.runtime !== detached.value.runtime || + entry.attachedThreadIds.has(ownerThreadId) + ) { + return; + } + yield* unloadThread({ providerThread }).pipe( + Effect.timeout(UNLOAD_THREAD_TIMEOUT_MS), + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.driver-session.detach-unload-failed", { + providerSessionId: input.providerSessionId, + threadId: ownerThreadId, + providerThreadId: providerThread.id, + cause, + }), ), - { concurrency: 1, discard: true }, - ); - }), - ); + ); + }), + ); + } } yield* scheduleIdleRelease(input.providerSessionId); }, diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 7932a03809c94..55e2a9e50e3ef 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -212,6 +212,7 @@ it.effect( getLimitRecoveryCandidates: () => Effect.die("unused getLimitRecoveryCandidates"), getLimitRecoveryWakeAt: () => Effect.die("unused getLimitRecoveryWakeAt"), getOpenRemoteDelegatedTasks: Effect.die("unused getOpenRemoteDelegatedTasks"), + getAgentAwarenessSnapshot: () => Effect.die("unused awareness snapshot"), getShellSnapshot: () => Effect.die("unused getShellSnapshot"), listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), countThreads: () => Effect.die("unused countThreads"), @@ -219,6 +220,7 @@ it.effect( listOlderSettledThreads: () => Effect.die("unused listOlderSettledThreads"), readShellSnapshot: () => Effect.die("unused readShellSnapshot"), getThreadShell: () => Effect.die("unused getThreadShell"), + getThreadTitles: () => Effect.die("unused getThreadTitles"), findGuestShareThreadId: () => Effect.die("unused findGuestShareThreadId"), getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)), getSettlementCandidates: () => Effect.die("unused getSettlementCandidates"), @@ -237,8 +239,10 @@ it.effect( getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), getSubagentCount: () => Effect.die("unused subagent count"), + listAppOwnedChildThreadIds: () => Effect.die("unused listAppOwnedChildThreadIds"), getNextTurnItemOrdinal: () => Effect.die("unused ordinal read"), getTurnItem: () => Effect.die("unused turn item read"), + getMessagesById: () => Effect.die("unused getMessagesById"), getThreadRecords: () => Effect.die("unused record read"), getRuntimeRequest: () => Effect.die("unused getRuntimeRequest"), getRunningTurnContext: () => Effect.die("unused getRunningTurnContext"), @@ -270,6 +274,7 @@ it.effect( getUnreadableThreadIds: () => Effect.die("unused getUnreadableThreadIds"), getThreadSnapshot: () => Effect.die("unused getThreadSnapshot"), getThreadSnapshotWindow: () => Effect.die("unused getThreadSnapshotWindow"), + getPullRequestSnapshot: () => Effect.die("unused getPullRequestSnapshot"), }), ); const layerSessionManager = Layer.succeed( diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index c9311433eecab..cdbd259440ab2 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it, vi } from "vite-plus/test"; import { it as effectIt } from "@effect/vitest"; @@ -279,6 +280,7 @@ function makeLocalCommandHarness(input: { readonly openFailure?: unknown; readonly ensureThreadFailure?: unknown; readonly historyReadFailureAfterFallback?: unknown; + readonly resumeFailure?: ProviderAdapter.ProviderAdapterV2Error; readonly interruptOpen?: boolean; readonly interruptRunBeforeOpenFailure?: boolean; readonly writeFailure?: unknown; @@ -487,6 +489,35 @@ function makeLocalCommandHarness(input: { strength: "strong" as const, }, }; + const parentProviderTurn = (ordinal: number, attemptRunId: RunId) => ({ + attempt: { + ...projection.attempts[0]!, + id: RunAttemptId.make(`side-chat-parent-attempt-${ordinal}`), + runId: attemptRunId, + providerThreadId: parentProviderThread.id, + status: "completed" as const, + }, + turn: { + id: ProviderTurnId.make(`side-chat-parent-turn-${ordinal}`), + providerThreadId: parentProviderThread.id, + nodeId: rootNodeId, + runAttemptId: RunAttemptId.make(`side-chat-parent-attempt-${ordinal}`), + nativeTurnRef: { + driver: providerThread.driver, + nativeId: `side-chat-parent-native-turn-${ordinal}`, + strength: "strong" as const, + }, + ordinal, + status: "completed" as const, + startedAt: now, + completedAt: now, + }, + }); + const parentRolledBackRunId = RunId.make("side-chat-parent-rolled-back-run"); + const parentTurns = [ + parentProviderTurn(1, parentRunId), + parentProviderTurn(2, parentRolledBackRunId), + ]; const parentRecords = { ...projection, thread: { ...projection.thread, id: parentThreadId }, @@ -497,17 +528,27 @@ function makeLocalCommandHarness(input: { threadId: parentThreadId, ordinal: 1, providerThreadId: parentProviderThread.id, - activeAttemptId: null, + activeAttemptId: parentTurns[0]!.attempt.id, status: "completed" as const, completedAt: now, }, + { + ...run, + id: parentRolledBackRunId, + threadId: parentThreadId, + ordinal: 2, + providerThreadId: parentProviderThread.id, + activeAttemptId: parentTurns[1]!.attempt.id, + status: "rolled_back" as const, + completedAt: now, + }, ...(input.sideChat?.nativeForkFrom === "busy" ? [ { ...run, id: RunId.make("side-chat-parent-busy-run"), threadId: parentThreadId, - ordinal: 2, + ordinal: 3, providerThreadId: parentProviderThread.id, status: "running" as const, }, @@ -515,8 +556,8 @@ function makeLocalCommandHarness(input: { : []), ], providerThreads: [parentProviderThread], - attempts: [], - providerTurns: [], + attempts: parentTurns.map(({ attempt }) => attempt), + providerTurns: parentTurns.map(({ turn }) => turn), turnItems: [], }; if (input.sideChat !== undefined) { @@ -608,7 +649,7 @@ function makeLocalCommandHarness(input: { ], }; } - if ("historyReadFailureAfterFallback" in input) { + if ("historyReadFailureAfterFallback" in input || "resumeFailure" in input) { const nativeThreadRef = { driver: providerThread.driver, nativeId: "native-resume-thread", @@ -715,17 +756,19 @@ function makeLocalCommandHarness(input: { ), ), ); + const fallbackEnsureThread = vi.fn(() => Effect.succeed(providerThread)); const resumeFallbackSession = { driver: providerThread.driver, resumeThread: () => Effect.fail( - new ProviderAdapter.ProviderAdapterEventStreamError({ - driver: providerThread.driver, - providerSessionId, - cause: "native thread is gone", - }), + input.resumeFailure ?? + new ProviderAdapter.ProviderAdapterEventStreamError({ + driver: providerThread.driver, + providerSessionId, + cause: "native thread is gone", + }), ), - ensureThread: () => Effect.succeed(providerThread), + ensureThread: fallbackEnsureThread, }; const providerSession = { id: providerSessionId, @@ -748,7 +791,9 @@ function makeLocalCommandHarness(input: { strength: "strong" as const, }, }; - const forkThread = vi.fn(() => Effect.succeed(sideChatThread)); + const forkThread = vi.fn((_fork: ProviderAdapter.ProviderAdapterV2ForkThreadInput) => + Effect.succeed(sideChatThread), + ); const sideChatSession = { driver: providerThread.driver, providerSession, @@ -762,7 +807,7 @@ function makeLocalCommandHarness(input: { ? Effect.succeed(sideChatSession as never) : input.interruptOpen === true ? Effect.interrupt - : "historyReadFailureAfterFallback" in input + : "historyReadFailureAfterFallback" in input || "resumeFailure" in input ? Effect.succeed(resumeFallbackSession as never) : "ensureThreadFailure" in input ? Effect.succeed({ driver: providerThread.driver, ensureThread } as never) @@ -970,6 +1015,7 @@ function makeLocalCommandHarness(input: { resolveRuntimePolicy, createWorktree, open, + fallbackEnsureThread, writeIfRunCurrent, startRootRun, startTurn, @@ -1188,7 +1234,7 @@ effectIt.effect( }, ]); expect(JSON.stringify(harness.events)).not.toContain("private git diagnostic"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); effectIt.effect("recovers an abandoned failed turn before continuing its native conversation", () => @@ -1272,6 +1318,10 @@ effectIt.effect.each([ yield* harness.start; expect(harness.forkThread).toHaveBeenCalledTimes(forks ? 1 : 0); + // The parent's rolled-back turn is gone from its native conversation. + expect( + harness.forkThread.mock.calls[0]?.[0].sourceProviderTurns?.map((turn) => turn.id), + ).toEqual(forks ? ["side-chat-parent-turn-1"] : undefined); expect(harness.projection().contextTransfers).toMatchObject([ { id: "side-chat-fork", status: transferStatus }, ]); @@ -1442,7 +1492,7 @@ effectIt.effect.each(["cleanup-first", "startup-first"])( runtimePolicy: expect.objectContaining({ cwd: worktreePath }), }), ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); effectIt.effect("reads current startup state after waiting for the workspace lease", () => @@ -1684,6 +1734,40 @@ effectIt.effect("does not overwrite a run interrupted while its provider session }), ); +effectIt.effect("fails the run instead of replacing a native thread another client holds", () => + Effect.gen(function* () { + const detail = "This conversation is open in another Codex client."; + const harness = makeLocalCommandHarness({ + text: "Continue", + resumeFailure: new ProviderAdapter.ProviderAdapterThreadInUseError({ + driver: ProviderDriverKind.make("codex"), + providerThreadId: ProviderThreadId.make("new-provider-thread"), + detail, + cause: new Error("thread native-resume-thread already has an active writer"), + }), + }); + + const exit = yield* Effect.exit(harness.start); + + expect(harness.fallbackEnsureThread).not.toHaveBeenCalled(); + expect(exit._tag).toBe("Success"); + expect(harness.startRootRun).not.toHaveBeenCalled(); + const projection = harness.projection(); + expect(projection.providerThreads.at(-1)?.nativeThreadRef?.nativeId).toBe( + "native-resume-thread", + ); + expect(projection.contextHandoffs).toEqual([]); + expect(projection.runs.at(-1)).toMatchObject({ status: "failed", startedAt: null }); + expect(projection.turnItems).toMatchObject([ + { + type: "error", + title: "Provider turn failed to start", + failure: { class: "provider_error", message: detail }, + }, + ]); + }), +); + effectIt.effect("does not mistake a failed state read for a superseded run", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ text: "Continue", failReadsAfterRunning: true }); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts index 5b614b1e2aa6c..fb3fb72fd76e3 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts @@ -1,4 +1,5 @@ import { SourceControlProviderError, SourceControlProviderKind } from "@t3tools/contracts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ServerConfig from "../config.ts"; import * as GitManager from "../git/GitManager.ts"; @@ -58,5 +59,5 @@ export const layerRealGit = GitWorkflow.layer.pipe( Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-recreate-" })), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 4a1fafd4f8906..c65fc9c1e9b52 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -295,8 +295,7 @@ export const layer: Layer.Layer< const request = turnItems.find((item) => item.type === "run_interrupt_request"); return request?.type === "run_interrupt_request" ? request.message : undefined; }), - // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. - Effect.catchCause(() => Effect.succeed(undefined)), + Effect.catchCause(() => Effect.undefined), ), }; }; @@ -1136,10 +1135,24 @@ export const layer: Layer.Layer< (candidate) => candidate.ordinal > sourceRun.ordinal && isActiveRun(candidate), ); if (!sourceBusy) { + // Rolled-back turns are no longer in the source conversation, so they cannot bound a fork. + const rolledBackAttemptIds = new Set( + sourceProjection.attempts + .filter((attempt) => + sourceProjection.runs.some( + (candidate) => + candidate.id === attempt.runId && candidate.status === "rolled_back", + ), + ) + .map((attempt) => attempt.id), + ); return yield* loadFromProvider( session.forkThread({ sourceProviderThread, - sourceProviderTurns: sourceProjection.providerTurns, + sourceProviderTurns: sourceProjection.providerTurns.filter( + (turn) => + turn.runAttemptId === null || !rolledBackAttemptIds.has(turn.runAttemptId), + ), targetThreadId: projection.thread.id, modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, @@ -1308,6 +1321,14 @@ export const layer: Layer.Layer< resumedHandoffContext = undefined; resumedRetryHandoff = []; if (!(yield* isCurrentAttemptInStatus("starting"))) return undefined; + // Another client is still writing to the native thread. A fresh + // session here would silently split the conversation from it. + if ( + resumed._tag === "Failure" && + resumed.failure._tag === "ProviderAdapterThreadInUseError" + ) { + return yield* loadFromProvider(Effect.fail(resumed.failure)); + } yield* Effect.logWarning("Recovering provider context in a fresh native session", { driver: session.driver, diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 93ddf77608007..51c11e24bc369 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -25,7 +25,7 @@ import { import { assert, describe, it } from "@effect/vitest"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -52,10 +52,7 @@ type SyncCommand = Extract< >; type LinkCommand = Extract; -const testCrypto = Crypto.make({ - randomBytes: (size) => new Uint8Array(size).fill(1), - digest: (_algorithm, data) => Effect.succeed(data), -}); +const testCrypto = SecureRandom.make((size) => new Uint8Array(size).fill(1)); function makeProject(id: ProjectId = PROJECT_ID): OrchestrationProjectShell { return { @@ -267,7 +264,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: streamDomainEvents: Stream.fromQueue(domainEvents), }), Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), - Layer.succeed(Crypto.Crypto, testCrypto), + Layer.succeed(SecureRandom.SecureRandom, testCrypto), ); return { @@ -958,6 +955,49 @@ describe("PullRequestSyncReactor", () => { ), ); + it.effect("stops reading a pull request a day after it closed, until a refresh asks", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const closed = (closedAt: string | null) => ({ state: "closed" as const, closedAt }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("closed", { + pullRequests: [ + makeLink(1, closed("2026-08-20T00:00:00.000Z")), + makeLink(2, closed("2026-08-26T00:00:00.000Z")), + makeLink(3, closed("2026-08-28T11:00:00.000Z")), + makeLink(4, closed(null)), + ], + }), + ]), + summary: (input) => + Effect.succeed(makeSummary(input, { state: "closed", closedAt: NOW })), + }); + const reads = Ref.get(fixture.summaryCalls).pipe( + Effect.map((calls) => calls.map((call) => call.number).toSorted()), + ); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + // Closed within the day, or with no close time, keeps the fifteen-minute cadence. + assert.deepStrictEqual(yield* reads, [3, 4]); + for (let index = 0; index < 15; index += 1) yield* sweepAgain(fixture, reactor); + assert.deepStrictEqual(yield* reads, [3, 3, 4, 4]); + + yield* reactor.requestSync({ + host: "github.com", + repository: "owner/repository", + number: 1, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual(yield* reads, [1, 3, 3, 4, 4]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("re-reads a merged pull request until the host supplies its merge time", () => Effect.scoped( Effect.gen(function* () { @@ -1500,46 +1540,55 @@ describe("PullRequestSyncReactor", () => { ), ); - it.effect("reads open links fresh as soon as a merge command ends, before its run ends", () => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - const state = yield* Ref.make("open"); - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([ - makeThread("agent", { pullRequests: [makeLink(7, { state: "open" })] }), - ]), - summary: (input) => - Ref.get(state).pipe( - Effect.map((current) => - makeSummary(input, current === "merged" ? { state: current, mergedAt: NOW } : {}), + it.effect.each(["open", "closed"] as const)( + "reads %s links fresh as soon as a merge command ends, before its run ends", + (initialState) => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make(initialState); + const initialSnapshot = { + state: initialState, + closedAt: initialState === "closed" ? "2026-08-20T00:00:00.000Z" : null, + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("agent", { pullRequests: [makeLink(7, initialSnapshot)] }), + ]), + summary: (input) => + Ref.get(state).pipe( + Effect.map((current) => + makeSummary( + input, + current === "merged" ? { state: current, mergedAt: NOW } : initialSnapshot, + ), + ), ), - ), - }); + }); - yield* Effect.gen(function* () { - const reactor = yield* startAndSweep(fixture); - yield* Ref.set(state, "merged"); - yield* Ref.set(fixture.summaryCalls, []); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* Ref.set(state, "merged"); + yield* Ref.set(fixture.summaryCalls, []); - yield* Queue.offer( - fixture.domainEvents, - commandRan(ThreadId.make("agent"), "glab mr merge 7"), - ); - yield* Queue.take(fixture.snapshotReads); - yield* Queue.take(fixture.snapshotReads); - yield* reactor.drain; + yield* Queue.offer( + fixture.domainEvents, + commandRan(ThreadId.make("agent"), "glab mr merge 7"), + ); + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; - assert.deepStrictEqual( - (yield* Ref.get(fixture.syncCommands)).map((command) => [ - command.number, - command.snapshot.state, - ]), - [[7, "merged"]], - ); - }).pipe(Effect.provide(fixture.layer)); - }), - ), + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => [ + command.number, + command.snapshot.state, + ]), + [[7, "merged"]], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), ); it.effect.each([ diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 2b3605e3a966e..9dffe4babb424 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -20,7 +20,7 @@ import { sourceControlDefinitions } from "../sourceControl/sourceControlDefiniti import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -44,6 +44,9 @@ const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; // requested refreshes, watches and a host CLI's merge command still read it at once. const QUIET_AFTER_MS = 30 * 60 * 1_000; const QUIET_SYNC_INTERVAL_MS = 5 * 60 * 1_000; +// A pull request closed this long ago is rarely reopened, so the sweep stops reading it, as it +// does merged ones. A reader that sees it open, or a requested refresh, still reads it at once. +const CLOSED_FINAL_AFTER_MS = 24 * 60 * 60 * 1_000; const isChangeRequestCloseCommand = (command: string) => sourceControlDefinitions.definitions.some( @@ -333,7 +336,7 @@ export const make = Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const pullRequests = yield* PullRequestService.PullRequestService; const git = yield* GitManager.GitManager; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const lastSyncedAt = new Map(); const requested = new Set(); @@ -351,13 +354,12 @@ export const make = Effect.gen(function* () { if (entries.some((entry) => entry.link.snapshot === null)) return true; const active = entries.filter((entry) => isUnsettled(entry.thread)); // Merged snapshots without mergedAt cannot settle threads; reread slowly until supplied. - if ( - active.every( - (entry) => entry.link.snapshot?.state === "merged" && entry.link.snapshot.mergedAt != null, - ) - ) { - return false; - } + const isFinal = ({ link: { snapshot } }: LinkEntry) => + snapshot?.state === "merged" + ? snapshot.mergedAt != null + : snapshot?.state === "closed" && + nowMs - Date.parse(snapshot.closedAt ?? "") >= CLOSED_FINAL_AFTER_MS; + if (active.every(isFinal)) return false; const last = lastSyncedAt.get(key); const sinceLastMs = last === undefined ? Number.POSITIVE_INFINITY : nowMs - last; const open = active.filter((entry) => entry.link.snapshot?.state === "open"); @@ -443,7 +445,7 @@ export const make = Effect.gen(function* () { } const url = siblingChangeRequestUrl(link.url, layer.number); if (url === null) continue; - const uuid = yield* crypto.randomUUIDv4(); + const uuid = yield* random.uuidV4(); yield* engine.dispatch({ type: "thread.pull-request.link", commandId: CommandId.make(`server:pr-stack-link:${thread.id}:${uuid}`), @@ -455,7 +457,7 @@ export const make = Effect.gen(function* () { linkedThisSweep.add(dedupeKey); } if (changed) { - const uuid = yield* crypto.randomUUIDv4(); + const uuid = yield* random.uuidV4(); yield* engine.dispatch({ type: "thread.pull-request-link.sync", commandId: CommandId.make(`server:pr-sync:${thread.id}:${uuid}`), @@ -627,13 +629,13 @@ export const make = Effect.gen(function* () { // Threads that ran a close command this run, with the commands already read for. const closeCommands = new Map>(); - const refreshOpenLinks = (threadId: ThreadId) => + const refreshUnmergedLinks = (threadId: ThreadId) => projections.getThreadsWithPullRequests(threadId).pipe( Effect.flatMap((threads) => Effect.forEach( threads.flatMap((thread) => visibleThreadPullRequests(thread.pullRequests ?? []).filter( - (link) => link.snapshot?.state === "open", + (link) => link.snapshot?.state === "open" || link.snapshot?.state === "closed", ), ), requestSync, @@ -698,7 +700,7 @@ export const make = Effect.gen(function* () { { discard: true }, ); // An agent can merge or close its pull request from a shell with its host's CLI, which - // sends no merge notification. Read the thread's open links fresh once that command + // sends no merge notification. Read the thread's unmerged links fresh once that command // ends, while the agent usually still replies, so settlement can land before the run // ends. Read again when the run ends, since `merge --auto` returns before it merges. case "turn-item.updated": { @@ -710,11 +712,11 @@ export const make = Effect.gen(function* () { closeCommands.set(event.threadId, ended); if (isOrchestrationV2WorkActive(item.status) || ended.has(item.id)) return linked; ended.add(item.id); - return Effect.andThen(linked, refreshOpenLinks(event.threadId)); + return Effect.andThen(linked, refreshUnmergedLinks(event.threadId)); } case "run.updated": return isTerminalRunStatus(event.payload.status) && closeCommands.delete(event.threadId) - ? refreshOpenLinks(event.threadId) + ? refreshUnmergedLinks(event.threadId) : Effect.void; default: return Effect.void; diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index c2c5632501687..55b4164d2fa07 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -17,7 +17,7 @@ import { import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -50,13 +50,13 @@ const FINGERPRINT_REREAD_MS = 30 * 60_000; const isProviderError = Schema.is(PullRequestProviderError); -const isRateLimited = (cause: Cause.Cause) => +const providerFailureReason = (cause: Cause.Cause) => Option.match(Cause.findErrorOption(cause), { - onNone: () => false, + onNone: () => null, onSome: (error) => - error._tag === "PullRequestOperationError" && - isProviderError(error.cause) && - error.cause.reason === "rate-limited", + error._tag === "PullRequestOperationError" && isProviderError(error.cause) + ? error.cause.reason + : null, }); const logFailure = @@ -196,7 +196,7 @@ export const make = Effect.gen(function* () { const engine = yield* Orchestrator.OrchestratorV2; const projections = yield* ProjectionStore.ProjectionStoreV2; const pullRequests = yield* PullRequestService.PullRequestService; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const bootedAt = yield* Clock.currentTimeMillis; const lives = new Map(); @@ -276,7 +276,7 @@ export const make = Effect.gen(function* () { wake?: { readonly text: string; readonly notification: OrchestrationV2Notification }, endedWatch?: ThreadPullRequestWatch, ) { - const uuid = yield* crypto.randomUUIDv4(); + const uuid = yield* random.uuidV4(); yield* engine.dispatch({ type: "thread.pull-request-watch.sync", commandId: CommandId.make(`server:pr-watch:${target.thread.id}:${uuid}`), @@ -444,7 +444,7 @@ export const make = Effect.gen(function* () { (cause): Effect.Effect => Cause.hasInterruptsOnly(cause) ? Effect.interrupt - : isRateLimited(cause) + : providerFailureReason(cause) === "rate-limited" ? Effect.succeed("paused") : Effect.logDebug("pull request watch fingerprint failed", { pullRequest: group.key, @@ -472,21 +472,33 @@ export const make = Effect.gen(function* () { const reference = referenceOf(group); // Invalidate cached reads before using the newer fingerprint; clients receive no notification. if (fingerprint !== null) yield* pullRequests.invalidate({ reference }); - const read = yield* Effect.exit( - Effect.all( - [ - pullRequests.detail({ ...reference, allowStale: false }), + const [detailRead, activityRead] = yield* Effect.all( + [ + Effect.exit(pullRequests.detail({ ...reference, allowStale: false })), + Effect.exit( plan.activity ? pullRequests.activity(reference).pipe(Effect.asSome) : Effect.succeed(Option.none()), - ], - { concurrency: 2 }, - ), + ), + ], + { concurrency: 2 }, ); + // Activity failures must not cancel or hide a permanent detail failure. + const read = Exit.isFailure(detailRead) + ? Exit.failCause(detailRead.cause) + : Exit.isFailure(activityRead) + ? Exit.failCause(activityRead.cause) + : Exit.succeed([detailRead.value, activityRead.value] as const); if (Exit.isFailure(read)) { if (Cause.hasInterruptsOnly(read.cause)) return yield* Effect.failCause(read.cause); lastReads.delete(group.key); - if (isRateLimited(read.cause)) return; + const reason = providerFailureReason(read.cause); + if (reason === "rate-limited") return; + // Transport and host failures can outlast the failure limit; keep the watch for recovery. + if (reason === "failed") { + readFailures.delete(group.key); + return yield* Effect.failCause(read.cause); + } const failures = (readFailures.get(group.key) ?? 0) + 1; readFailures.set(group.key, failures); if (failures >= READ_FAILURE_LIMIT && (yield* eachTarget(group, giveUp))) { @@ -496,9 +508,9 @@ export const make = Effect.gen(function* () { } readFailures.delete(group.key); const [detail, activity] = read.value; - const headSha = detail.headSha ?? null; for (const target of group.targets) { const life = lifeOf(target); + const headSha = detail.headSha ?? life.headSha; const pushed = life.headSha !== null && headSha !== life.headSha; lives.set(lifeKey(target), { ...life, diff --git a/apps/server/src/orchestration-v2/ResourceCleanupService.ts b/apps/server/src/orchestration-v2/ResourceCleanupService.ts index 4ad8a6e8951ae..e9e4cdbc6a4ed 100644 --- a/apps/server/src/orchestration-v2/ResourceCleanupService.ts +++ b/apps/server/src/orchestration-v2/ResourceCleanupService.ts @@ -63,9 +63,9 @@ export const layer = Layer.effect( ); }), // Coder: there is no browser preview, so a thread has no preview sessions to close. - cleanupPreviews: () => Effect.void, + cleanupPreviews: Effect.fn("ResourceCleanupService.cleanupPreviews")(function* () {}), // Coder: deleting or reverting a thread never deletes its attachments. - cleanupAttachments: () => Effect.void, + cleanupAttachments: Effect.fn("ResourceCleanupService.cleanupAttachments")(function* () {}), }; }), ); diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts index 24269dfeaff24..954ba2f31a3eb 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts @@ -113,6 +113,13 @@ it("names a lost subagent by the id the agent resumes it with", () => { parentTaskId: "a".repeat(400), }).label; assert.lengthOf(longId, 160); + assert.isTrue( + cancelledRosterTaskWork({ + kind: "command", + taskId: "shell-1", + description: `${"d".repeat(158)}🚀 deploy`, + }).label.isWellFormed(), + ); assert.isFalse(isAwaitedWork(cancelledRosterTaskWork({ kind: "command", taskId: "shell-2" }))); assert.isTrue( isAwaitedWork(cancelledRosterTaskWork({ kind: "command", taskId: "tests", awaited: true })), diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts index 24afd61d6186a..c495290ad0813 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts @@ -17,7 +17,11 @@ function compactLabel(value: unknown, maxLength = MAX_LABEL_LENGTH): string | un if (typeof value !== "string") return undefined; const text = value.replaceAll(/\s+/g, " ").trim(); if (text.length === 0) return undefined; - return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text; + if (text.length <= maxLength) return text; + // Half an emoji serializes to a lone surrogate escape, which provider JSON parsers reject. + const last = text.charCodeAt(maxLength - 2); + const end = last >= 0xd800 && last <= 0xdbff ? maxLength - 2 : maxLength - 1; + return `${text.slice(0, end)}…`; } /** A bounded label that keeps its id suffix whole whenever it fits. */ diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 642bc656c21cb..71371382e2c7d 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -36,6 +36,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; +import { CodexAppServerRequestError } from "effect-codex-app-server/errors"; import { SqlError, classifySqliteError } from "effect/sql/SqlError"; import { toPersistenceSqlError } from "../persistence/Errors.ts"; @@ -2638,6 +2639,35 @@ it.effect("refreshes pull requests after a provider stream exits with an error", }), ); +it.effect("records a provider's input rejection at turn start in the terminal error", () => + Effect.gen(function* () { + const rejection = "goal objective must be at most 4000 characters"; + const { written, observed } = yield* captureRootRunTermination({ + key: "start-failure:input-rejected", + shouldFinalizeRun: () => Effect.succeed(true), + events: () => Stream.never, + afterStart: (closed) => Deferred.succeed(closed, undefined), + startTurn: (turnInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause: new CodexAppServerRequestError({ + code: -32600, + errorMessage: rejection, + method: "thread/goal/set", + }), + }), + ), + }); + assert.deepEqual(observed, ["run:failed", "pull-requests-refreshed"]); + const error = written.find((item) => item.type === "error"); + assert.include(error?.failure.message ?? "", rejection); + }), +); + it.effect.each(["failure", "defect", "interrupt-failure", "ownership-lost"] as const)( "stops the live provider and background work before settling an ingestion %s", (failureMode) => diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index 38a18192531a5..b51ec8fb80de0 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -5,8 +5,12 @@ import { ThreadId, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as TestClock from "effect/testing/TestClock"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; @@ -93,6 +97,7 @@ it.effect.each( ).map((scenario) => [scenario.label, scenario] as const), )("%s", ([, scenario]) => { const refreshed: string[] = []; + const lookedUp = Deferred.makeUnsafe(); const threadId = ThreadId.make("thread-pr-refresh"); const runId = RunId.make("completed-run"); const layer = RunFinalization.layerObserver.pipe( @@ -114,11 +119,11 @@ it.effect.each( }), refreshStatus: () => Effect.die("turn completion must preserve known PRs and lookup backoff"), - refreshPullRequestStatus: (cwd) => - Effect.sync(() => { - refreshed.push(cwd); - return null; - }), + // Requested synchronously by refresh; the lookup itself runs in the background. + refreshPullRequestStatus: (cwd) => { + refreshed.push(cwd); + return Deferred.succeed(lookedUp, undefined).pipe(Effect.as(null)); + }, }), Layer.mock(ProjectionStore.ProjectionStoreV2)({ getThreadShell: () => @@ -134,6 +139,105 @@ it.effect.each( return Effect.gen(function* () { const observer = yield* RunFinalization.RunFinalizationObserver; yield* observer.refresh({ cwd: "/repo", threadId, runId }); + if (scenario.expected.length > 0) yield* Deferred.await(lookedUp); assert.deepEqual(refreshed, [...scenario.expected]); }).pipe(Effect.provide(layer)); }); + +it.effect("returns from the post-turn refresh without waiting for the PR lookup", () => { + const threadId = ThreadId.make("thread-slow-forge"); + const runId = RunId.make("completed-run"); + const layer = RunFinalization.layerObserver.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), + Layer.mock(PullRequestService.PullRequestService)({ + refreshAfterTurn: () => Effect.void, + }), + Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ + refreshLocalStatus: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + refreshPullRequestStatus: () => Effect.never, + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: () => + Effect.succeed({ + id: threadId, + branch: "feature", + activeRunId: null, + } as OrchestrationV2ThreadShell), + }), + ), + ), + ); + return Effect.gen(function* () { + const observer = yield* RunFinalization.RunFinalizationObserver; + const refresh = yield* observer + .refresh({ cwd: "/repo", threadId, runId }) + .pipe(Effect.timeoutOption("1 minute"), Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 minute"); + const result = yield* Fiber.join(refresh); + assert.isTrue( + Option.isSome(result), + "turn finalization waited on the forge PR lookup, blocking the thread's next turn", + ); + }).pipe(Effect.provide(layer)); +}); + +it.effect("replaces a still-running PR lookup when a newer turn finishes", () => { + const threadId = ThreadId.make("thread-back-to-back"); + const runId = RunId.make("completed-run"); + const firstInterrupted = Deferred.makeUnsafe(); + const secondLookedUp = Deferred.makeUnsafe(); + let lookups = 0; + const layer = RunFinalization.layerObserver.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), + Layer.mock(PullRequestService.PullRequestService)({ + refreshAfterTurn: () => Effect.void, + }), + Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ + refreshLocalStatus: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + refreshPullRequestStatus: () => + ++lookups === 1 + ? Effect.never.pipe( + Effect.onInterrupt(() => Deferred.succeed(firstInterrupted, undefined)), + ) + : Deferred.succeed(secondLookedUp, undefined).pipe(Effect.as(null)), + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: () => + Effect.succeed({ + id: threadId, + branch: "feature", + activeRunId: null, + } as OrchestrationV2ThreadShell), + }), + ), + ), + ); + return Effect.gen(function* () { + const observer = yield* RunFinalization.RunFinalizationObserver; + yield* observer.refresh({ cwd: "/repo", threadId, runId }); + yield* observer.refresh({ cwd: "/repo", threadId, runId }); + yield* Deferred.await(firstInterrupted); + yield* Deferred.await(secondLookedUp); + assert.strictEqual(lookups, 2); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.ts b/apps/server/src/orchestration-v2/RunFinalizationService.ts index 40882033ba527..3775e63f0e733 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.ts @@ -1,6 +1,7 @@ import { CheckpointScopeId, ProjectId, RunId, ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as FiberMap from "effect/FiberMap"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -96,6 +97,7 @@ export const layerObserver = Layer.effect( const vcsStatus = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const projections = yield* ProjectionStore.ProjectionStoreV2; const pullRequests = yield* PullRequestService.PullRequestService; + const pullRequestLookups = yield* FiberMap.make(); return { refreshAfterTurn: pullRequests.refreshAfterTurn, refresh: Effect.fn("RunFinalizationService.refresh")( @@ -108,6 +110,8 @@ export const layerObserver = Layer.effect( const thread = yield* projections.getThreadShell(threadId); if (!thread || thread.branch !== local.refName) return; if (thread.activeRunId !== null && thread.activeRunId !== runId) return; + // The forge CLI can take minutes, so the next turn must not wait for it. A newer + // turn's lookup replaces a running one instead of queueing behind the cwd's lock. yield* vcsStatus.refreshPullRequestStatus(cwd).pipe( Effect.catch((error) => Effect.logWarning("failed to refresh pull request status after run completion", { @@ -116,6 +120,7 @@ export const layerObserver = Layer.effect( detail: error.message, }), ), + FiberMap.run(pullRequestLookups, cwd), ); }, (effect, { cwd }) => diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index d410be8ddf1c3..beb1f3412131b 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -107,6 +107,8 @@ const layerTest = RuntimePolicy.layerFromProjectStore.pipe( ); it.layer(layerTest)("RuntimePolicyV2", (it) => { + // Coder: no Antigravity case (#23698); Antigravity is not carried, and a mode the provider does + // not offer steps down to the broadest offered mode below it. it.effect("runs a mode the provider does not offer in Supervised", () => Effect.gen(function* () { const policy = yield* RuntimePolicy.RuntimePolicyV2; diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 840a24a3da18a..bd3f4e5b2e7d8 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import { + ChatAttachmentId, CommandId, MessageId, EventId, @@ -43,13 +44,16 @@ it.effect.each( [true, "with interrupting native steering"], [true, "settled only"], [false, "during continuation"], + ["question", "during delivery"], ] as const - ).map(([mailbox, timing]) => ({ - mailbox, + ).map(([kind, timing]) => ({ + mailbox: kind === true, + question: kind === "question", timing, - label: mailbox ? "mailbox notification" : "steering", + label: + kind === "question" ? "delegated task question" : kind ? "mailbox notification" : "steering", })), -)("delivers $label when completion wins $timing", ({ mailbox, timing }) => +)("delivers $label when completion wins $timing", ({ mailbox, question, timing }) => Effect.scoped( Effect.gen(function* () { const cwd = yield* checkpointWorkspace(`steering-completion-${timing.replaceAll(" ", "-")}`); @@ -254,6 +258,21 @@ it.effect.each( ], }); } + if (question) { + const { thread } = yield* orchestrator.getThreadProjection(threadId); + yield* (yield* EventSink.EventSinkV2).write({ + events: [ + { + id: EventId.make("event:composer-selection"), + type: "thread.model-selection-updated", + threadId, + providerInstanceId: instanceId, + occurredAt: yield* DateTime.now, + payload: { ...thread, modelSelection: composerSelection }, + }, + ], + }); + } const dispatchSteer = orchestrator.dispatch({ type: "message.dispatch", commandId: CommandId.make("steer"), @@ -269,11 +288,21 @@ it.effect.each( sizeBytes: 10, }, ], - dispatchMode: mailbox - ? { type: "queue_after_active" } - : { type: "steer_active", targetRunId: first.runId }, - createdBy: mailbox ? "agent" : "user", - creationSource: mailbox ? "server" : "web", + dispatchMode: + mailbox || question + ? { type: "queue_after_active" } + : { type: "steer_active", targetRunId: first.runId }, + createdBy: mailbox || question ? "agent" : "user", + creationSource: mailbox || question ? "server" : "web", + ...(question + ? { + notification: { + source: { kind: "delegated_task", taskIds: [taskId] }, + outcome: "updated", + summary: "Delegated task has a question", + }, + } + : {}), ...(mailbox ? { delegatedCompletion: { @@ -339,6 +368,8 @@ it.effect.each( if (delivery !== null) { yield* Deferred.succeed(rejectSteer, undefined); yield* Fiber.join(delivery); + } + if (delivery !== null && !question) { const requeued = yield* orchestrator.getThreadProjection(threadId); const item = requeued.turnItems.find( (item) => item.type === "user_message" && item.messageId === messageId, @@ -398,7 +429,11 @@ it.effect.each( ]); assert.equal(steerCalls, timing === "during delivery" ? 1 : 0); const final = yield* orchestrator.getThreadProjection(threadId); - if (!mailbox) { + if (question) { + assert.deepEqual(final.thread.modelSelection, composerSelection); + assert.deepEqual(started[1]?.modelSelection, composerSelection); + } + if (!mailbox && !question) { assert.equal( final.turnItems.find( (item) => item.type === "user_message" && item.messageId === messageId, @@ -413,7 +448,7 @@ it.effect.each( ); assert.equal( final.turnItems.filter((item) => - mailbox + mailbox || question ? item.type === "notification" : item.type === "user_message" && item.messageId === messageId, ).length, @@ -449,6 +484,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* canRestart = false, canSteer = true, interruptTurn: Effect.Effect = Effect.void, + steeringAcceptsAttachments = true, ) { const cwd = yield* checkpointWorkspace(name); const events = yield* Queue.unbounded(); @@ -461,6 +497,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* ...CodexProviderCapabilitiesV2.turns, supportsActiveSteering: canSteer, supportsSteeringByInterruptRestart: canRestart, + activeSteeringAcceptsAttachments: steeringAcceptsAttachments, }, }; const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { @@ -471,28 +508,33 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* openSession: (input) => Effect.gen(function* () { const now = yield* DateTime.now; + const turnUpdated = ( + turn: ProviderAdapter.ProviderAdapterV2TurnInput, + status: "running" | "interrupted", + ) => + Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: { + driver, + nativeId: `native:${turn.attemptId}`, + strength: "strong", + }, + ordinal: turn.providerTurnOrdinal, + status, + startedAt: now, + completedAt: status === "running" ? null : now, + }, + }); const startTurn = (turn: ProviderAdapter.ProviderAdapterV2TurnInput) => Effect.gen(function* () { started.push(turn); - yield* Queue.offer(events, { - type: "provider_turn.updated", - driver, - providerTurn: { - id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), - providerThreadId: turn.providerThread.id, - nodeId: turn.rootNodeId, - runAttemptId: turn.attemptId, - nativeTurnRef: { - driver, - nativeId: `native:${turn.attemptId}`, - strength: "strong", - }, - ordinal: turn.providerTurnOrdinal, - status: "running", - startedAt: now, - completedAt: null, - }, - }); + yield* turnUpdated(turn, "running"); }); return { instanceId, @@ -539,7 +581,19 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* interruptTurn: ({ providerTurnId }) => Effect.sync(() => { interrupted.push(providerTurnId); - }).pipe(Effect.andThen(interruptTurn)), + }).pipe( + Effect.andThen(interruptTurn), + Effect.andThen( + Effect.forEach( + started.filter( + (turn) => + ProviderTurnId.make(`provider-turn:${turn.attemptId}`) === providerTurnId, + ), + (turn) => turnUpdated(turn, "interrupted"), + { discard: true }, + ), + ), + ), respondToRuntimeRequest: () => Effect.void, readThreadSnapshot: () => Effect.die("unused"), rollbackThread: () => Effect.die("unused"), @@ -597,6 +651,65 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* return { cwd, events, started, steered, interrupted, layer, startFirstTurn }; }); +it.effect("steers a delegated task question notice into the running parent turn", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "delegated-question-steer", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const { thread } = yield* orchestrator.getThreadProjection(threadId); + yield* (yield* EventSink.EventSinkV2).write({ + events: [ + { + id: EventId.make("event:composer-selection"), + type: "thread.model-selection-updated", + threadId, + providerInstanceId: instanceId, + occurredAt: yield* DateTime.now, + payload: { ...thread, modelSelection: composerSelection }, + }, + ], + }); + const messageId = MessageId.make("message:delegated-task-question:child-question"); + const notification = { + source: { + kind: "delegated_task" as const, + taskIds: [NodeId.make("task:question")], + childThreadId: ThreadId.make("thread:question-child"), + }, + outcome: "updated" as const, + summary: "Delegated task has a question", + }; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("question-wake"), + threadId, + messageId, + text: "Delegated task asked a question", + notification, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + }); + yield* worker.drain(); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["Delegated task asked a question"]); + assert.lengthOf(started, 1); + assert.isFalse(after.runs.some((run) => run.status === "queued")); + const message = after.messages.find((candidate) => candidate.id === messageId); + assert.equal(message?.runId, started[0]?.runId); + assert.deepEqual(message?.notification, notification); + assert.deepEqual(after.thread.modelSelection, composerSelection); + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("sends a selected held message once and preserves the remaining queue order", () => Effect.scoped( Effect.gen(function* () { @@ -878,6 +991,54 @@ it.effect( ), ); +it.effect("restarts the turn for an image when native steering takes text only", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "steering-text-only-attachment", + true, + true, + Effect.void, + false, + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("steer-image"), + threadId, + messageId: MessageId.make("message:steer-image"), + text: "look at this", + attachments: [ + { + type: "image", + id: ChatAttachmentId.make("steer-image"), + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 4, + }, + ], + modelSelection: runSelection, + dispatchMode: { type: "steer_active", targetRunId: started[0]!.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + assert.deepEqual(steered, []); + assert.deepEqual( + started.map((turn) => [turn.message.text, turn.message.attachments.length]), + [ + ["first", 0], + ["look at this", 1], + ], + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("starts a steer that missed the turn on the saved next-turn selection", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index 96c7263173f32..d955755e92122 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -852,7 +852,11 @@ const forkSideChat = (commandId: string, sourceThreadId: ThreadId, targetThreadI }); }); -const askSideChat = (threadId: ThreadId, commandId: string) => +const askSideChat = ( + threadId: ThreadId, + commandId: string, + modelSelection?: (typeof forkCases)[number]["modelSelection"], +) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; yield* orchestrator.dispatch({ @@ -862,6 +866,7 @@ const askSideChat = (threadId: ThreadId, commandId: string) => messageId: MessageId.make(commandId), text: "What is the main thread doing?", attachments: [], + ...(modelSelection === undefined ? {} : { modelSelection }), dispatchMode: { type: "start_immediately" }, createdBy: "user", creationSource: "web", @@ -954,10 +959,17 @@ it.effect("a side chat of an idle parent still starts with the framing note", () query.includes("FROM orchestration_v2_projection_context_transfers"), ), ); - yield* askSideChat(sideChatId, "side-chat-idle-first-message"); + yield* askSideChat(sideChatId, "side-chat-idle-first-message", { + ...forkCases[0]!.modelSelection, + model: "side-chat-model", + }); const started = yield* orchestrator.getThreadProjection(sideChatId); assert.isNotNull(started.providerThreads[0]?.forkedFrom); + // The side chat does not show the parent's run, so it is no baseline. + assert.isFalse( + started.turnItems.some((item) => item.type === "user_message" && item.modelSwitch), + ); assert.lengthOf(started.contextHandoffs, 1); const [handoff] = started.contextHandoffs; assert.isTrue( diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 42f2ac0e0a00d..da00706c9a815 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -167,6 +167,10 @@ function makeHarness(options: HarnessOptions = {}) { (_input: Parameters[0]) => Effect.void, ); + const deleteLocalBranch = vi.fn( + (_input: Parameters[0]) => + Effect.void, + ); const runSetup = vi.fn( options.runSetup ?? (() => Effect.succeed({ status: "no-script" as const })), ); @@ -211,6 +215,7 @@ function makeHarness(options: HarnessOptions = {}) { remoteExists: options.remoteExists ?? (() => Effect.succeed(true)), remoteBranchExists: () => Effect.succeed(true), removeWorktree, + deleteLocalBranch, ...options.git, }), Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, { @@ -264,7 +269,12 @@ function makeHarness(options: HarnessOptions = {}) { }); const layerTitleRegeneration = ThreadTitleRegeneration.layer.pipe( Layer.provide( - Layer.mergeAll(layerThreadManagement, layerProjectedProjects, layerExternalServices), + Layer.mergeAll( + layerThreadManagement, + layerProjectedProjects, + layerExternalServices, + NodeServices.layer, + ), ), ); return { @@ -279,6 +289,7 @@ function makeHarness(options: HarnessOptions = {}) { ), createWorktree, removeWorktree, + deleteLocalBranch, renameBranch, generateBranchName, generateThreadTitle, @@ -1111,7 +1122,7 @@ it.effect.each(["stable", "upstream/release"])( Effect.provide( GitVcsDriver.layer.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "launch-remote-git-" })), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), ), @@ -2186,8 +2197,15 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch it.effect("cancels tracked setup before provider work is released", () => Effect.gen(function* () { const entered = yield* Deferred.make(); + const renamed = yield* Deferred.make(); const harness = makeHarness({ - runSetup: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)), + renameBranch: (input) => + Deferred.succeed(renamed, undefined).pipe(Effect.as({ branch: input.newBranch })), + runSetup: () => + Deferred.await(renamed).pipe( + Effect.andThen(Deferred.succeed(entered, undefined)), + Effect.andThen(Effect.never), + ), }); yield* Effect.gen(function* () { const launches = yield* ThreadLaunch.ThreadLaunchService; @@ -2208,11 +2226,50 @@ it.effect("cancels tracked setup before provider work is released", () => const projection = yield* threads.getThreadProjection(launched.threadId); assert.equal(projection.runs[0]?.status, "failed"); assert.isNull(projection.thread.worktreePath); + assert.deepEqual(harness.deleteLocalBranch.mock.calls[0]?.[0], { + cwd: "/repo", + refName: "generated-branch", + force: true, + }); assert.isEmpty(yield* outbox.listByCommandId(CommandId.make(`${input.commandId}:release`))); }).pipe(Effect.provide(harness.layer)); }), ); +it.effect( + "deletes a claimed worktree's branch when setup is cancelled before checkout returns", + () => + Effect.gen(function* () { + const claimed = yield* Deferred.make(); + const harness = makeHarness({ + createWorktree: (_input, options) => + (options?.progress?.onWorktreeClaimed?.("/repo-worktrees/feature") ?? Effect.void).pipe( + Effect.andThen(Deferred.succeed(claimed, undefined)), + Effect.andThen(Effect.never), + ), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; + const launched = yield* launches.launch( + launchInput({ + command: "launch:cancel-claimed", + thread: "thread:cancel-claimed", + message: "Start", + workspace: { type: "worktree", baseRef: "main" }, + }), + ); + yield* Deferred.await(claimed); + assert.isTrue(yield* tracker.cancel(launched.threadId)); + assert.equal(harness.removeWorktree.mock.calls[0]?.[0].path, "/repo-worktrees/feature"); + assert.equal( + harness.deleteLocalBranch.mock.calls[0]?.[0].refName, + harness.createWorktree.mock.calls[0]?.[0].newRefName, + ); + }).pipe(Effect.provide(harness.layer)); + }), +); + it.effect("stops an in-flight branch rename when tracked setup is cancelled", () => Effect.gen(function* () { const setupEntered = yield* Deferred.make(); @@ -2257,6 +2314,10 @@ it.effect("stops an in-flight branch rename when tracked setup is cancelled", () ); assert.equal(harness.renameBranch.mock.calls.length, 0); assert.isNull((yield* threads.getThreadProjection(launched.threadId)).thread.worktreePath); + assert.equal( + harness.deleteLocalBranch.mock.calls[0]?.[0].refName, + harness.createWorktree.mock.calls[0]?.[0].newRefName, + ); }).pipe(Effect.provide(harness.layer)); }), ); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 4ab38c4b305e3..7e7829ba947cf 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -310,6 +310,7 @@ const make = Effect.gen(function* () { const reused = input.reusedWorktree; const tracked = input.workspaceStrategy.type === "worktree" || reused !== undefined; let createdWorktreePath: string | null = null; + let createdBranch: string | null = null; let setupTerminalId: string | null = null; let workspaceRecorded = false; let runReleased = false; @@ -448,6 +449,7 @@ const make = Effect.gen(function* () { onWorktreeClaimed: (path) => Effect.sync(() => { createdWorktreePath = path; + createdBranch = branch; }), onCheckoutProgress: (progress) => setupTracker.stage(threadId, "checkout", { percent: progress.percent }), @@ -458,6 +460,7 @@ const make = Effect.gen(function* () { worktreePath = worktree.worktree.path; branch = worktree.worktree.refName; createdWorktreePath = worktreePath; + createdBranch = branch; yield* setupTracker.update(threadId, (snapshot) => ({ ...snapshot, worktreePath, branch })); yield* setupTracker.stageStatus(threadId, "checkout", "done"); } @@ -487,12 +490,22 @@ const make = Effect.gen(function* () { const worktreeCwd = worktreePath; branchRename = yield* generateBranchNameFor(worktreeCwd, initialMessage).pipe( Effect.flatMap(({ branch: newBranch, exactName }) => - git.renameBranch({ - cwd: worktreeCwd, - oldBranch, - newBranch, - ...(exactName ? { exactName: true } : {}), - }), + git + .renameBranch({ + cwd: worktreeCwd, + oldBranch, + newBranch, + ...(exactName ? { exactName: true } : {}), + }) + .pipe( + // A cancel must see the renamed branch, or its cleanup deletes the old name. + Effect.tap((renamed) => + Effect.sync(() => { + createdBranch = renamed.branch; + }), + ), + Effect.uninterruptible, + ), ), Effect.flatMap((renamed) => Effect.gen(function* () { @@ -680,10 +693,22 @@ const make = Effect.gen(function* () { ) { if (branchRename !== null) yield* Fiber.interrupt(branchRename); const removedPath = createdWorktreePath; + const removedBranch = createdBranch; // Forget worktrees only after removal succeeds; failed removals must not reuse partially written checkouts. yield* git .removeWorktree({ cwd: project.workspaceRoot, path: removedPath, force: true }) .pipe( + Effect.andThen( + removedBranch === null + ? Effect.void + : git + .deleteLocalBranch({ + cwd: project.workspaceRoot, + refName: removedBranch, + force: true, + }) + .pipe(Effect.ignoreCause({ log: true })), + ), Effect.andThen( threads .dispatch({ diff --git a/apps/server/src/orchestration-v2/ThreadLiveEventCoalescer.ts b/apps/server/src/orchestration-v2/ThreadLiveEventCoalescer.ts index b3b8af0e64d96..00adbb0cb5445 100644 --- a/apps/server/src/orchestration-v2/ThreadLiveEventCoalescer.ts +++ b/apps/server/src/orchestration-v2/ThreadLiveEventCoalescer.ts @@ -95,7 +95,8 @@ export const makeThreadLiveEventCoalescer = Effect.fn( let windowFiber: Fiber.Fiber | null = null; let closed = false; - const cancelWindow = Effect.fn("ThreadLiveEventCoalescer.cancelWindow")(function* () { + // cancelWindow/flushPending/offerAll run per live event or batch; sub-ms spans here would flood the rotating trace. + const cancelWindow = Effect.fnUntraced(function* () { const fiber = windowFiber; if (!fiber) { return; @@ -104,7 +105,7 @@ export const makeThreadLiveEventCoalescer = Effect.fn( yield* Fiber.interrupt(fiber); }); - const flushPending = Effect.fn("ThreadLiveEventCoalescer.flushPending")(function* () { + const flushPending = Effect.fnUntraced(function* () { if (pendingUpdates.length === 0) { return; } @@ -135,9 +136,7 @@ export const makeThreadLiveEventCoalescer = Effect.fn( // Keep each source batch together so a synchronization marker cannot pass // events already pulled from PubSub but still being coalesced. - const offerAll = Effect.fn("ThreadLiveEventCoalescer.offerAll")(function* ( - inputs: ReadonlyArray, - ) { + const offerAll = Effect.fnUntraced(function* (inputs: ReadonlyArray) { yield* mutex.withPermits(1)( Effect.forEach( inputs, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index fcffd831f2971..4307968897339 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -403,6 +403,7 @@ export interface ThreadManagementServiceShape { ProjectionRecords, ThreadManagementProjectionLoadError | ThreadManagementThreadNotFoundError >; + readonly getAgentAwarenessSnapshot: Orchestrator.OrchestratorV2["Service"]["getAgentAwarenessSnapshot"]; readonly getShellSnapshot: ( options?: Pick< ShellSnapshotOptions, @@ -420,6 +421,8 @@ export interface ThreadManagementServiceShape { Orchestrator.OrchestratorV2Error >; readonly getThreadShell: Orchestrator.OrchestratorV2["Service"]["getThreadShell"]; + readonly listAppOwnedChildThreadIds: Orchestrator.OrchestratorV2["Service"]["listAppOwnedChildThreadIds"]; + readonly getThreadTitles: Orchestrator.OrchestratorV2["Service"]["getThreadTitles"]; /** Omit `projectId` to list every project's threads. Side chats are never listed. */ readonly listProjectThreads: (input: { readonly projectId?: ProjectId | undefined; @@ -1520,6 +1523,7 @@ const make = Effect.gen(function* () { getThreadTranscript, getThreadSnapshotWindow, getProjectThreadRecords, + getAgentAwarenessSnapshot: orchestrator.getAgentAwarenessSnapshot, getShellSnapshot: orchestrator.getShellSnapshot, listActiveSubagentThreadIds: orchestrator.listActiveSubagentThreadIds, countThreads: orchestrator.countThreads, @@ -1527,6 +1531,14 @@ const make = Effect.gen(function* () { listOlderSettledThreads: orchestrator.listOlderSettledThreads, readShellSnapshot: orchestrator.readShellSnapshot, getThreadShell: orchestrator.getThreadShell, + listAppOwnedChildThreadIds: Effect.fn("ThreadManagementService.listAppOwnedChildThreadIds")( + function (threadId) { + return ensureProjectionTranscript(threadId).pipe( + Effect.andThen(orchestrator.listAppOwnedChildThreadIds(threadId)), + ); + }, + ), + getThreadTitles: orchestrator.getThreadTitles, listProjectThreads, sendToThread, assertProviderInstanceEnabled, diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 7fb2b3369126f..6aaa22b43859d 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -9,13 +9,15 @@ import { type OrchestrationV2ServerCommand, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; 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 Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -26,6 +28,10 @@ import * as ProjectStore from "./ProjectStore.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerActivation from "../serverActivation.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import { EventId } from "@t3tools/contracts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ThreadPullRequestService from "./ThreadPullRequestService.ts"; @@ -103,15 +109,13 @@ describe("ThreadPullRequestServiceV2 reads", () => { const dependencies = Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({ streamDomainEvents: Stream.fromQueue(domainEvents), - getShellSnapshot: () => + getPullRequestSnapshot: () => Effect.succeed({ schemaVersion: 2, snapshotSequence: 1, threads: [thread], archivedThreads: [], }), - getThreadEventSequence: () => Effect.succeed(1), - getThreadShell: () => Effect.succeed(thread), }), Layer.mock(ProjectStore.ProjectStoreV2)({ listShells: () => Effect.succeed([project]), @@ -148,13 +152,11 @@ describe("ThreadPullRequestServiceV2 reads", () => { }), Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size).fill(1), - digest: (_algorithm, data) => Effect.succeed(data), - }), + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size).fill(1)), ), FileSystem.layerNoop({}), + Path.layer, ); yield* Effect.gen(function* () { const service = yield* ThreadPullRequestService.make; @@ -179,6 +181,129 @@ describe("ThreadPullRequestServiceV2 reads", () => { ), ); + it.effect.each([ + { + worktree: "a linked worktree", + existingPaths: ["/workspace/worktree", "/workspace/worktree/.git"], + unreadablePaths: [], + cwd: "/workspace/worktree", + }, + // A folder that outlived its .git makes git fail with not_a_repository. + { + worktree: "a folder without .git", + existingPaths: ["/workspace/worktree"], + unreadablePaths: [], + cwd: "/workspace/project", + }, + { + worktree: "a folder whose .git cannot be checked", + existingPaths: ["/workspace/worktree"], + unreadablePaths: ["/workspace/worktree/.git"], + cwd: "/workspace/worktree", + }, + { + worktree: "a missing folder", + existingPaths: [], + unreadablePaths: [], + cwd: "/workspace/project", + }, + ] as const)( + "looks up a branch whose worktree is $worktree in $cwd", + ({ existingPaths, unreadablePaths, cwd }) => + Effect.scoped( + Effect.gen(function* () { + const thread = { + ...threadShell("worktree-thread"), + settledOverride: "settled" as const, + settledAt: NOW, + branch: "feature/worktree", + worktreePath: "/workspace/worktree", + }; + const project: OrchestrationProjectShell = { + id: thread.projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; + const activation = yield* Deferred.make(); + const sweepStarted = yield* Deferred.make(); + const lookupCwds: Array = []; + const dependencies = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + streamDomainEvents: Stream.never, + getPullRequestSnapshot: () => + Deferred.succeed(sweepStarted, undefined).pipe( + Effect.as({ + schemaVersion: 2, + snapshotSequence: 1, + threads: [thread], + archivedThreads: [], + }), + ), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => Effect.succeed([project]), + getShell: () => Effect.succeedSome(project), + }), + Layer.mock(GitManager.GitManager)({ + branchPullRequest: (input) => + Effect.sync(() => { + lookupCwds.push(input.cwd); + return null; + }), + }), + Layer.mock(PullRequestService.PullRequestService)({}), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:acme/project.git", + }, + provider: SourceControlProviderKind.make("github"), + owner: "acme", + name: "project", + }), + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), + Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), + Layer.succeed( + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size).fill(1)), + ), + FileSystem.layerNoop({ + exists: (path) => + unreadablePaths.some((entry) => entry === path) + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: path, + }), + ) + : Effect.succeed(existingPaths.some((entry) => entry === path)), + }), + Path.layer, + ); + yield* Effect.gen(function* () { + const service = yield* ThreadPullRequestService.make; + yield* service.start(); + yield* Deferred.succeed(activation, undefined); + yield* Deferred.await(sweepStarted); + yield* service.drain; + }).pipe(Effect.provide(dependencies)); + + expect(lookupCwds).toEqual([cwd]); + }), + ), + ); + it.effect("leaves delegated child threads to their own refresh instead of the sweep", () => Effect.scoped( Effect.gen(function* () { @@ -214,18 +339,17 @@ describe("ThreadPullRequestServiceV2 reads", () => { const dependencies = Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({ streamDomainEvents: Stream.fromQueue(domainEvents), - getShellSnapshot: () => - Queue.offer(sweeps, undefined).pipe( - Effect.as({ - schemaVersion: 2, - snapshotSequence: 1, - threads: [parent, child], - archivedThreads: [], - }), - ), - getThreadEventSequence: () => Effect.succeed(1), - getThreadShell: (threadId) => - Effect.succeed([parent, child].find((thread) => thread.id === threadId) ?? null), + getPullRequestSnapshot: ({ threadId }) => + threadId !== undefined + ? Effect.succeed({ snapshotSequence: 1, threads: [child] }) + : Queue.offer(sweeps, undefined).pipe( + Effect.as({ + schemaVersion: 2, + snapshotSequence: 1, + threads: [parent, child], + archivedThreads: [], + }), + ), }), Layer.mock(ProjectStore.ProjectStoreV2)({ listShells: () => Effect.succeed([project]), @@ -263,13 +387,11 @@ describe("ThreadPullRequestServiceV2 reads", () => { }), Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size).fill(1), - digest: (_algorithm, data) => Effect.succeed(data), - }), + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size).fill(1)), ), FileSystem.layerNoop({}), + Path.layer, ); yield* Effect.gen(function* () { const service = yield* ThreadPullRequestService.make; @@ -338,14 +460,37 @@ describe("ThreadPullRequestServiceV2 reads", () => { const dependencies = Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({ streamDomainEvents: Stream.never, - getShellSnapshot: () => - Deferred.succeed(sweepStarted, undefined).pipe( - Effect.as({ - schemaVersion: 2, - snapshotSequence: 1, - threads: [thread], - archivedThreads: [], - }), + getPullRequestSnapshot: (options) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + yield* store.apply({ + id: EventId.make(`event:${thread.id}:created`), + type: "thread.created", + threadId: thread.id, + occurredAt: NOW, + payload: { ...thread, lastVisitedAt: thread.lastVisitedAt ?? null }, + }); + const sql = yield* SqlClient.SqlClient; + yield* sql`INSERT INTO orchestration_events ( + sequence, event_id, aggregate_kind, stream_id, stream_version, event_type, + occurred_at, actor_kind, payload_json, metadata_json, application_event_version + ) VALUES (7, 'pr-discovery', 'thread', ${thread.id}, 1, 'thread.created', + '2026-09-20T00:00:00.000Z', 'system', '{}', '{}', 2)`; + for (const table of [ + "orchestration_v2_projection_turn_items", + "orchestration_v2_projection_messages", + "orchestration_v2_projection_provider_threads", + "orchestration_v2_projection_runs", + ]) + yield* sql.unsafe(`DROP TABLE ${table}`); + const snapshot = yield* store.getPullRequestSnapshot(options); + yield* Deferred.succeed(sweepStarted, undefined); + return snapshot; + }).pipe( + Effect.provide( + ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)), + ), + Effect.orDie, ), dispatch: (command) => Effect.sync(() => { @@ -389,13 +534,11 @@ describe("ThreadPullRequestServiceV2 reads", () => { Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size).fill(1), - digest: (_algorithm, data) => Effect.succeed(data), - }), + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size).fill(1)), ), FileSystem.layerNoop({}), + Path.layer, ); yield* Effect.gen(function* () { const service = yield* ThreadPullRequestService.make; @@ -412,6 +555,14 @@ describe("ThreadPullRequestServiceV2 reads", () => { expect(commands[0]).toMatchObject({ type: "thread.pull-request.sync", threadId: thread.id, + snapshotSequence: 7, + expected: { + workspaceRoot: project.workspaceRoot, + branch: thread.branch, + worktreePath: thread.worktreePath, + linkedPullRequest: savedReference, + branchPullRequest: savedReference, + }, branchPullRequest: null, }); expect(commands[0]).not.toHaveProperty("linkedPullRequest"); diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 0db9feac8587e..1cb01acd4ed9c 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -7,13 +7,13 @@ import { type ThreadLinkedPullRequest, } from "@t3tools/contracts"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -100,8 +100,9 @@ export const make = Effect.gen(function* () { const git = yield* GitManager.GitManager; const pullRequests = yield* PullRequestService.PullRequestService; const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const pendingBackfill = new Map(); @@ -118,19 +119,9 @@ export const make = Effect.gen(function* () { }; const readThreadSnapshot = ({ threadId, backfill }: RefreshRequest) => - threadId === null - ? orchestrator - .getShellSnapshot({ - location: "active", - unsettledOnly: !(backfill || pendingBackfill.size > 0), - }) - .pipe(NodeSqliteClient.readOnly) - : Effect.gen(function* () { - // Read sequence first so guarded sync rejects intervening changes. - const snapshotSequence = yield* orchestrator.getThreadEventSequence(threadId); - const thread = yield* orchestrator.getThreadShell(threadId); - return { snapshotSequence, threads: thread === null ? [] : [thread] }; - }); + orchestrator.getPullRequestSnapshot( + threadId === null ? { unsettledOnly: !(backfill || pendingBackfill.size > 0) } : { threadId }, + ); const synchronize = Effect.fn("ThreadPullRequestServiceV2.synchronize")(function* ( request: RefreshRequest, @@ -188,10 +179,13 @@ export const make = Effect.gen(function* () { refresh: request.refresh, }); if (first.branch !== null && repository === null) return finishBackfill(group); - const worktreeExists = - first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath)); + // A worktree folder can outlive its .git, and git there fails with not_a_repository. + // An unreadable .git keeps the worktree rather than failing the whole group. const cwd = - worktreeExists && first.worktreePath !== null + first.worktreePath !== null && + (yield* fileSystem + .exists(path.join(first.worktreePath, ".git")) + .pipe(Effect.orElseSucceed(() => true))) ? first.worktreePath : project.workspaceRoot; const detected = @@ -306,7 +300,7 @@ export const make = Effect.gen(function* () { if (!projectWorkspaceMatchesSnapshot(currentProject, project.workspaceRoot)) { return failBackfill([thread]); } - const uuid = yield* crypto.randomUUIDv4(); + const uuid = yield* random.uuidV4(); yield* orchestrator.dispatch({ type: "thread.pull-request.sync", commandId: CommandId.make(`server:thread-pull-request:${thread.id}:${uuid}`), diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 28ac06b6c8153..260bc3abe4dfa 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -20,11 +20,13 @@ import { type ServerSettingsPatch, } from "@t3tools/contracts"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; 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 PlatformError from "effect/PlatformError"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -465,10 +467,7 @@ describe("autoSettlementSettingsKey", () => { type AutoSettleCommand = Extract; -const testCrypto = Crypto.make({ - randomBytes: (size) => new Uint8Array(size).fill(1), - digest: (_algorithm, data) => Effect.succeed(data), -}); +const testCrypto = SecureRandom.make((size) => new Uint8Array(size).fill(1)); function makeProject( id: ProjectId = PROJECT_ID, @@ -556,6 +555,7 @@ interface HarnessOptions { readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"]; readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"]; readonly existingWorktreePaths?: ReadonlyArray; + readonly unreadablePaths?: ReadonlyArray; readonly onDispatch?: (command: AutoSettleCommand) => Effect.Effect; readonly onScriptRun?: ( input: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput, @@ -703,10 +703,21 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: }), Layer.succeed(ServerSettings.ServerSettingsService, serverSettings), Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), - Layer.succeed(Crypto.Crypto, testCrypto), + Layer.succeed(SecureRandom.SecureRandom, testCrypto), FileSystem.layerNoop({ - exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false), + exists: (path) => + options.unreadablePaths?.includes(path) + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: path, + }), + ) + : Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false), }), + Path.layer, ); return { @@ -900,6 +911,117 @@ describe("ThreadSettlementServiceV2 worker", () => { ), ); + it.effect("looks up a branch in its worktree only while the worktree has .git", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("linked-worktree", { + branch: "linked", + worktreePath: "/worktrees/linked", + latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + }), + // A folder that outlived its .git makes git fail with not_a_repository. + makeThread("emptied-worktree", { + branch: "emptied", + worktreePath: "/worktrees/emptied", + latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + }), + ]), + existingWorktreePaths: [ + "/worktrees/linked", + "/worktrees/linked/.git", + "/worktrees/emptied", + ], + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + const cwdByBranch = Object.fromEntries( + (yield* Ref.get(fixture.branchCalls)).map((call) => [call.branch, call.cwd]), + ); + expect(cwdByBranch).toEqual({ + linked: "/worktrees/linked", + emptied: "/workspace/project", + }); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("does not settle a thread whose worktree cannot be checked", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("unreadable-worktree", { + branch: "unreadable", + worktreePath: "/worktrees/unreadable", + latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + }), + // A merged linked pull request still needs the branch recheck before settling. + makeThread("unreadable-linked-worktree", { + branch: "feature", + worktreePath: "/worktrees/unreadable-linked", + latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 41, + url: "https://example.test/owner/repository/pull/41", + }, + }), + makeThread("readable-worktree", { + branch: "readable", + worktreePath: "/worktrees/readable", + latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + }), + ], + [ + { + ...makeProject(), + repositoryIdentity: { + canonicalKey: "example.test/owner/repository", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://example.test/owner/repository.git", + }, + provider: SourceControlProviderKind.make("github"), + owner: "owner", + name: "repository", + }, + }, + ], + ), + existingWorktreePaths: ["/worktrees/readable", "/worktrees/readable/.git"], + unreadablePaths: ["/worktrees/unreadable/.git", "/worktrees/unreadable-linked/.git"], + pullRequestSummary: (input) => + Effect.succeed({ + ...makePullRequestSummary({ ...input, state: "merged" }), + mergedAt: NOW, + }), + branchPullRequest: (input) => + input.cwd.startsWith("/worktrees/unreadable") + ? Effect.die(new Error("git: permission denied")) + : Effect.succeed(makeBranchPullRequest("merged")), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([ + ThreadId.make("readable-worktree"), + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("reevaluates immediately after a pull request merge", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index fa0bf3dff448e..8c2dcfe8868cc 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -14,11 +14,12 @@ import { import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FiberSet from "effect/FiberSet"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Schedule from "effect/Schedule"; import * as Semaphore from "effect/Semaphore"; import type * as Scope from "effect/Scope"; @@ -295,8 +296,9 @@ export const make = Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; const git = yield* GitManager.GitManager; const pullRequests = yield* PullRequestService.PullRequestService; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; // A shell merge or close sends no event, and the branch lookup caches an open pull request for // a minute, so these threads' next lookup reads the host fresh. const shellClosedThreads = new Set(); @@ -339,7 +341,7 @@ export const make = Effect.gen(function* () { autoSettleKeepPullRequests: currentSettings.sidebarAutoSettleKeepPullRequests, }); if (settledAt === null) return thread; - const uuid = yield* crypto.randomUUIDv4(); + const uuid = yield* random.uuidV4(); yield* orchestrator.dispatch({ type: "thread.auto-settle", commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), @@ -394,12 +396,14 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const project = projects.get(thread.projectId); if (project === undefined || thread.branch === null) return; - const worktreeExists = - thread.worktreePath !== null && - (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false))); + // A worktree folder can outlive its .git, and git there fails with not_a_repository. + // An unreadable .git keeps the worktree, so git fails there and the thread is skipped. lookupCwdByThreadId.set( thread.id, - worktreeExists && thread.worktreePath !== null + thread.worktreePath !== null && + (yield* fileSystem + .exists(path.join(thread.worktreePath, ".git")) + .pipe(Effect.orElseSucceed(() => true))) ? thread.worktreePath : project.workspaceRoot, ); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 8f7de819f5718..c54e6e1f4815d 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it, vi } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, ChatAttachmentId, @@ -14,6 +15,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; @@ -98,6 +100,7 @@ function makeHarness( layerProjectedProjects, Layer.mock(TextGeneration.TextGeneration)({ generateThreadTitle }), layerServerSettings, + NodeServices.layer, ), ), ); @@ -113,7 +116,11 @@ function makeHarness( }; } -function createThread(input: { readonly command: string; readonly thread: string }) { +function createThread(input: { + readonly command: string; + readonly thread: string; + readonly worktreePath?: string; +}) { return Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; const threadId = ThreadId.make(input.thread); @@ -127,7 +134,7 @@ function createThread(input: { readonly command: string; readonly thread: string runtimeMode: "full-access", interactionMode: "default", branch: null, - worktreePath: null, + worktreePath: input.worktreePath ?? null, createdBy: "user", creationSource: "web", }); @@ -391,31 +398,43 @@ describe("ThreadTitleRegenerationService", () => { const harness = makeHarness({ generateTitle: () => Effect.succeed({ title: "Fresh title" }), }); + const fs = yield* FileSystem.FileSystem; + const existingWorktree = yield* fs.makeTempDirectoryScoped({ prefix: "title-worktree-" }); yield* Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; - const threadId = yield* createThread({ - command: "command:title:landing:create", - thread: "thread:title:landing", - }); - yield* dispatchUserMessage({ - command: "command:title:landing:message", - threadId, - text: "Investigate the flaky login test", - }); - const requestId = yield* armRegeneration({ command: "command:title:landing:1", threadId }); + const cases = [ + { name: "removed", worktreePath: "/repo-worktrees/removed-by-cleanup", cwd: "/repo" }, + { name: "existing", worktreePath: existingWorktree, cwd: existingWorktree }, + ]; + for (const [index, testCase] of cases.entries()) { + const threadId = yield* createThread({ + command: `command:title:landing:${testCase.name}:create`, + thread: `thread:title:landing:${testCase.name}`, + worktreePath: testCase.worktreePath, + }); + yield* dispatchUserMessage({ + command: `command:title:landing:${testCase.name}:message`, + threadId, + text: "Investigate the flaky login test", + }); + const requestId = yield* armRegeneration({ + command: `command:title:landing:${testCase.name}:1`, + threadId, + }); - yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } }); + yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } }); - const call = harness.generateThreadTitle.mock.calls[0]?.[0]; - assert.equal(call?.previousTitle, "Seed title"); - assert.equal(call?.cwd, "/repo"); - assert.include(call?.message, "USER:\nInvestigate the flaky login test"); - const projection = yield* threads.getThreadProjection(threadId); - assert.equal(projection.thread.title, "Fresh title"); - assert.isNotOk(projection.thread.titleRegeneration); + const call = harness.generateThreadTitle.mock.calls[index]?.[0]; + assert.equal(call?.previousTitle, "Seed title"); + assert.equal(call?.cwd, testCase.cwd); + assert.include(call?.message, "USER:\nInvestigate the flaky login test"); + const projection = yield* threads.getThreadProjection(threadId); + assert.equal(projection.thread.title, "Fresh title"); + assert.isNotOk(projection.thread.titleRegeneration); + } }).pipe(Effect.provide(harness.layer)); - }), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("titles a fork's first message with the source history up to the fork point", () => diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index 601b783675d0e..6c80def39059f 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -13,6 +13,7 @@ import { import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; @@ -50,6 +51,7 @@ const make = Effect.gen(function* () { const projects = yield* ProjectStore.ProjectStoreV2; const serverSettings = yield* ServerSettings.ServerSettingsService; const textGeneration = yield* TextGeneration.TextGeneration; + const fileSystem = yield* FileSystem.FileSystem; const complete = (input: { readonly threadId: ThreadId; @@ -160,8 +162,13 @@ const make = Effect.gen(function* () { yield* serverSettings.getSettings, projection.thread.projectId, ).settings; + // Storage cleanup removes worktrees but keeps the path; CLIs cannot spawn in a missing cwd. + const worktreePath = projection.thread.worktreePath; + const worktreeExists = + worktreePath !== null && + (yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => false))); const result = yield* textGeneration.generateThreadTitle({ - cwd: projection.thread.worktreePath ?? project.value.workspaceRoot, + cwd: worktreeExists ? worktreePath : project.value.workspaceRoot, message: context.message, attachments: context.attachments, ...(input.kind.type === "regenerate" ? { previousTitle: projection.thread.title } : {}), diff --git a/apps/server/src/orchestration-v2/TurnItemCompaction.test.ts b/apps/server/src/orchestration-v2/TurnItemCompaction.test.ts deleted file mode 100644 index 5799c5fbdcd31..0000000000000 --- a/apps/server/src/orchestration-v2/TurnItemCompaction.test.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { applyOrchestrationV2ProjectionEvent } from "../../../../packages/client-runtime/src/state/orchestrationV2Projection.ts"; -import { assert, it } from "@effect/vitest"; -import { - CommandId, - EventId, - NodeId, - PlanId, - type OrchestrationV2AppThread, - type OrchestrationV2DomainEvent, - type OrchestrationV2TurnItem, - ProjectId, - ProviderDriverKind, - ProviderInstanceId, - ThreadId, - TurnItemId, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Stream from "effect/Stream"; -import * as Tracer from "effect/Tracer"; -import * as SqlClient from "effect/sql/SqlClient"; -import * as Statement from "effect/sql/Statement"; - -import * as SqlitePersistence from "../persistence/Sqlite.ts"; -import * as EventSink from "./EventSink.ts"; -import * as EventStore from "./EventStore.ts"; -import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; -import * as ProjectionStore from "./ProjectionStore.ts"; - -const database = SqlitePersistence.layerMemory; -const eventStoreProvided = EventStore.layer.pipe(Layer.provideMerge(database)); -const projectionStoreProvided = ProjectionStore.layer.pipe(Layer.provideMerge(database)); -const stores = Layer.mergeAll(database, eventStoreProvided, projectionStoreProvided); -const layerTest = Layer.mergeAll( - stores, - EventSink.layer.pipe(Layer.provide(stores)), - ProjectionMaintenance.layer.pipe(Layer.provide(stores)), -); - -const providerInstanceId = ProviderInstanceId.make("codex"); -const providerDriver = ProviderDriverKind.make("codex"); -const modelSelection = { instanceId: providerInstanceId, model: "gpt-5.4" }; - -function makeThread(threadId: ThreadId, now: DateTime.Utc): OrchestrationV2AppThread { - return { - id: threadId, - projectId: ProjectId.make(`project:${threadId}`), - title: "Turn item compaction", - providerInstanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: null, - lineage: { rootThreadId: threadId, parentThreadId: null, relationshipToParent: null }, - forkedFrom: null, - createdBy: "user", - creationSource: "web", - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }; -} - -function threadCreated(thread: OrchestrationV2AppThread, now: DateTime.Utc) { - return { - id: EventId.make("event:turn-item-compaction:thread"), - type: "thread.created", - threadId: thread.id, - providerInstanceId, - occurredAt: now, - payload: thread, - } satisfies OrchestrationV2DomainEvent; -} - -const itemSpecs = [ - { key: "command", type: "command_execution", ordinal: 10 }, - { key: "reasoning", type: "reasoning", ordinal: 20 }, - { key: "todo", type: "todo_list", ordinal: 30 }, - { key: "commanded", type: "command_execution", ordinal: 40 }, -] as const; - -function itemEvent( - threadId: ThreadId, - now: DateTime.Utc, - spec: (typeof itemSpecs)[number], - revision: number, - eventId = `event:turn-item-compaction:${spec.key}:${revision}`, -): OrchestrationV2DomainEvent { - const id = TurnItemId.make(`turn-item:compaction:${spec.key}`); - const growing = `${spec.key}:${revision}:` + "x".repeat((revision + 1) * 192); - const completed = revision === 69; - const base = { - id, - threadId, - runId: null, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: spec.ordinal, - status: completed ? ("completed" as const) : ("running" as const), - title: spec.key, - startedAt: now, - completedAt: completed ? now : null, - updatedAt: now, - }; - let payload: OrchestrationV2TurnItem; - switch (spec.type) { - case "command_execution": - payload = { - ...base, - type: "command_execution", - input: `run ${spec.key}`, - output: growing, - }; - break; - case "reasoning": - payload = { ...base, type: "reasoning", text: growing, streaming: !completed }; - break; - case "todo_list": - payload = { - ...base, - type: "todo_list", - planId: PlanId.make("plan:turn-item-compaction"), - steps: [{ id: "step", text: growing, status: completed ? "completed" : "running" }], - explanation: growing, - }; - break; - } - return { - id: EventId.make(eventId), - type: "turn-item.updated", - threadId, - occurredAt: now, - payload, - }; -} - -function subagentEvents( - threadId: ThreadId, - now: DateTime.Utc, - revision: number, -): ReadonlyArray { - const id = NodeId.make("subagent:turn-item-compaction"); - const prompt = revision === 0 ? "Review prompt A. ".repeat(40) : "Review prompt B. ".repeat(40); - const progress = `Review progress ${revision}`; - return [ - { - id: EventId.make(`event:turn-item-compaction:subagent:${revision}`), - type: "subagent.updated", - threadId, - occurredAt: now, - payload: { - id, - threadId, - runId: null, - parentNodeId: NodeId.make("node:turn-item-compaction-parent"), - origin: "provider_native", - createdBy: "agent", - driver: providerDriver, - providerInstanceId, - providerThreadId: null, - childThreadId: null, - nativeTaskRef: { - driver: providerDriver, - nativeId: "compaction-review", - strength: "strong", - }, - prompt, - progress, - title: "Review", - model: null, - status: "running", - result: null, - startedAt: now, - completedAt: null, - updatedAt: now, - }, - }, - { - id: EventId.make(`event:turn-item-compaction:subagent-item:${revision}`), - type: "turn-item.updated", - threadId, - occurredAt: now, - payload: { - id: TurnItemId.make("turn-item:compaction:subagent"), - threadId, - runId: null, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 50, - status: "running", - title: "Review", - startedAt: now, - completedAt: null, - updatedAt: now, - type: "subagent", - subagentId: id, - origin: "provider_native", - driver: providerDriver, - providerInstanceId, - childThreadId: null, - prompt, - progress, - result: null, - }, - }, - ] satisfies ReadonlyArray; -} - -it.effect( - "compacts ordinary snapshots while preserving replay, prompts, command lookup, and positions", - () => - Effect.gen(function* () { - const sink = yield* EventSink.EventSinkV2; - const eventStore = yield* EventStore.EventStoreV2; - const projections = yield* ProjectionStore.ProjectionStoreV2; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; - const sql = yield* SqlClient.SqlClient; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread:turn-item-compaction"); - const thread = makeThread(threadId, now); - const writes = [...(yield* sink.write({ events: [threadCreated(thread, now)] }))]; - const commandId = CommandId.make("command:turn-item-compaction:middle"); - const snapshotsPerItem = 70; - for (let revision = 0; revision < snapshotsPerItem; revision += 1) { - const order = - revision === 0 - ? [itemSpecs[0], itemSpecs[1], itemSpecs[2], itemSpecs[3]] - : [itemSpecs[1], itemSpecs[0], itemSpecs[2], itemSpecs[3]]; - const events = order - .filter((spec) => spec.key !== "commanded" || revision !== 35) - .map((spec) => itemEvent(threadId, now, spec, revision)); - if (revision < 3) events.push(...subagentEvents(threadId, now, revision)); - const stored = yield* sink.write({ events }); - writes.push(...stored); - if (revision === 35) { - const middle = itemEvent( - threadId, - now, - itemSpecs[3], - revision, - "event:turn-item-compaction:commanded:middle", - ); - writes.push(...(yield* sink.write({ commandId, events: [middle] }))); - } - } - const beforeProjection = yield* projections.getThreadProjection(threadId); - const highWater = yield* eventStore.latestSequence(); - const bytes = yield* sql<{ readonly rows: number; readonly payload_bytes: number }>` - SELECT COUNT(*) AS rows, COALESCE(SUM(octet_length(payload_json)), 0) AS payload_bytes - FROM orchestration_events WHERE stream_id = ${threadId} - `; - const summary = yield* maintenance.compactEventStore(); - const replay = yield* eventStore.read({ threadId }).pipe(Stream.runCollect); - const expectedIds = new Set([ - "event:turn-item-compaction:thread", - ...itemSpecs.flatMap((spec) => [ - `event:turn-item-compaction:${spec.key}:0`, - `event:turn-item-compaction:${spec.key}:${snapshotsPerItem - 1}`, - ]), - "event:turn-item-compaction:commanded:middle", - ...Array.from({ length: 3 }, (_, revision) => [ - `event:turn-item-compaction:subagent:${revision}`, - `event:turn-item-compaction:subagent-item:${revision}`, - ]).flat(), - ]); - assert.deepEqual( - replay, - writes.filter((stored) => expectedIds.has(stored.event.id)), - ); - assert.lengthOf(replay, 16); - assert.equal(summary.deletedEventCount, bytes[0]!.rows - replay.length); - assert.isBelow( - (yield* sql<{ readonly payload_bytes: number }>` - SELECT COALESCE(SUM(octet_length(payload_json)), 0) AS payload_bytes - FROM orchestration_events WHERE stream_id = ${threadId} - `)[0]!.payload_bytes, - bytes[0]!.payload_bytes, - ); - assert.equal(yield* eventStore.latestSequence(), highWater); - - const commanded = yield* sink.readByCommandId({ commandId }).pipe(Stream.runCollect); - assert.deepEqual( - commanded.map((stored) => stored.event.id), - [EventId.make("event:turn-item-compaction:commanded:middle")], - ); - const storedPrompts = yield* sql<{ - readonly event_id: string; - readonly sequence: number; - readonly prompt: string; - readonly prompt_sequence: number | null; - }>` - SELECT event_id, sequence, - json_extract(payload_json, '$.prompt') AS prompt, - json_extract(payload_json, '$.promptSequence') AS prompt_sequence - FROM orchestration_events - WHERE stream_id = ${threadId} AND event_type = 'subagent.updated' - ORDER BY sequence ASC - `; - assert.lengthOf(storedPrompts, 3); - assert.equal(storedPrompts[0]!.prompt_sequence, null); - assert.equal(storedPrompts[1]!.prompt_sequence, null); - assert.equal(storedPrompts[2]!.prompt, ""); - assert.equal(storedPrompts[2]!.prompt_sequence, storedPrompts[1]!.sequence); - - const deletedCursor = writes.find( - (stored) => stored.event.id === "event:turn-item-compaction:command:35", - )!.sequence; - assert.isFalse(replay.some((stored) => stored.sequence === deletedCursor)); - const cursors = [0, deletedCursor, highWater]; - for (const cursor of cursors) { - const cursorReplay = yield* eventStore - .read({ threadId, afterSequence: cursor, throughSequence: highWater }) - .pipe(Stream.runCollect); - const expected = replay.filter((stored) => stored.sequence > cursor); - assert.deepEqual( - cursorReplay.map((stored) => stored.sequence), - expected.map((stored) => stored.sequence), - ); - - let clientProjection: typeof beforeProjection | null = { - ...beforeProjection, - subagents: [], - turnItems: [], - visibleTurnItems: [], - }; - let latestLocalTurnOrdinal: number | null = null; - for (const stored of [ - ...writes.filter((entry) => entry.sequence <= cursor), - ...cursorReplay, - ]) { - clientProjection = applyOrchestrationV2ProjectionEvent(clientProjection, stored.event, { - partialTimeline: true, - latestLocalTurnOrdinal, - }); - if (stored.event.type === "turn-item.updated") { - latestLocalTurnOrdinal = Math.max( - latestLocalTurnOrdinal ?? 0, - stored.event.payload.ordinal, - ); - } - } - assert.deepEqual(clientProjection?.turnItems, beforeProjection.turnItems); - assert.deepEqual(clientProjection?.visibleTurnItems, beforeProjection.visibleTurnItems); - } - assert.isTrue((yield* maintenance.verify).valid); - assert.isTrue((yield* maintenance.rebuild).valid); - assert.deepEqual(yield* projections.getThreadProjection(threadId), beforeProjection); - const positions = yield* sql<{ - readonly turn_item_id: string; - readonly ordinal: number; - }>` - SELECT turn_item_id, ordinal - FROM orchestration_v2_turn_item_positions - WHERE thread_id = ${threadId} - ORDER BY ordinal ASC - `; - assert.deepEqual( - positions.map((row) => [row.turn_item_id, row.ordinal]), - [ - ...itemSpecs.map((spec, index) => [`turn-item:compaction:${spec.key}`, index + 1]), - ["turn-item:compaction:subagent", 5], - ], - ); - }).pipe(Effect.provide(layerTest)), -); - -it.effect("keeps post-bound appends visible during compaction", () => - Effect.scoped( - Effect.gen(function* () { - const sink = yield* EventSink.EventSinkV2; - const eventStore = yield* EventStore.EventStoreV2; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread:turn-item-compaction-race"); - const thread = makeThread(threadId, now); - const initial = yield* sink.write({ - events: [ - threadCreated(thread, now), - ...[0, 1, 2].map((revision) => itemEvent(threadId, now, itemSpecs[0], revision)), - ], - }); - const beforeSequence = initial.at(-1)!.sequence; - - const streamReady = yield* Deferred.make(); - const streamTracer = Tracer.make({ - span(options) { - const span = new Tracer.NativeSpan(options); - const end = span.end.bind(span); - span.end = (endTime, exit) => { - end(endTime, exit); - const query = span.attributes.get("db.query.text"); - if (typeof query === "string" && query.includes("MAX(sequence)")) { - Deferred.doneUnsafe(streamReady, Effect.void); - } - }; - return span; - }, - }); - const liveEvents = yield* sink - .stream({ threadId, afterSequence: beforeSequence }) - .pipe( - Stream.take(3), - Stream.runCollect, - Effect.withTracer(streamTracer), - Effect.forkScoped, - ); - yield* Deferred.await(streamReady); - - const scanStarted = yield* Deferred.make(); - const releaseScan = yield* Deferred.make(); - let blocked = false; - const blockFirstScanPage: Statement.Transformer = (statement) => { - const [query] = statement.compile(); - if ( - !blocked && - query.includes("FROM orchestration_events AS event") && - query.includes("ORDER BY event.sequence DESC") - ) { - blocked = true; - return Deferred.succeed(scanStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseScan)), - Effect.as(statement), - ); - } - return Effect.succeed(statement); - }; - const compaction = yield* maintenance - .compactEventStore() - .pipe( - Effect.provideService(Statement.CurrentTransformer, blockFirstScanPage), - Effect.forkScoped, - ); - yield* Deferred.await(scanStarted); - const snapshots = [3, 4, 5].map((revision) => - itemEvent( - threadId, - now, - itemSpecs[0], - revision, - `event:turn-item-compaction-race:${revision}`, - ), - ); - const appended = yield* sink.write({ events: snapshots }); - yield* Deferred.succeed(releaseScan, undefined); - assert.equal((yield* Fiber.join(compaction)).deletedEventCount, 1); - const streamed = yield* Fiber.join(liveEvents); - assert.deepEqual( - streamed.map((stored) => stored.sequence), - appended.map((stored) => stored.sequence), - ); - assert.deepEqual( - streamed.map((stored) => stored.event.id), - snapshots.map((event) => event.id), - ); - assert.equal(yield* eventStore.latestSequence(), appended.at(-1)!.sequence); - const firstPassRows = yield* eventStore - .read({ threadId, eventType: "turn-item.updated" }) - .pipe(Stream.runCollect); - assert.deepEqual( - firstPassRows.map((stored) => stored.sequence), - [initial[1]!.sequence, beforeSequence, ...appended.map((stored) => stored.sequence)], - ); - yield* maintenance.compactEventStore(); - const secondPassRows = yield* eventStore - .read({ threadId, eventType: "turn-item.updated" }) - .pipe(Stream.runCollect); - assert.deepEqual( - secondPassRows.map((stored) => stored.sequence), - [initial[1]!.sequence, appended.at(-1)!.sequence], - ); - }), - ).pipe(Effect.provide(layerTest)), -); diff --git a/apps/server/src/orchestration-v2/TurnStartReads.test.ts b/apps/server/src/orchestration-v2/TurnStartReads.test.ts index 6db6b4c308827..7ae5aafa49466 100644 --- a/apps/server/src/orchestration-v2/TurnStartReads.test.ts +++ b/apps/server/src/orchestration-v2/TurnStartReads.test.ts @@ -406,11 +406,30 @@ it.effect.each(["sqlite"] as const)( }, }, }); + const recordingId = "thread-completion-reads-0b9f6c1e-3c1d-4f7a-9d2e-5a6b7c8d9e0f-webm"; + yield* store.apply({ + id: EventId.make("attachment:recording-event"), + type: "turn-item.updated", + threadId, + runId: oldRunId, + occurredAt: now, + payload: { + ...item, + id: TurnItemId.make("attachment:recording"), + type: "dynamic_tool", + toolName: "mcp__t3-code__preview_recording_stop", + input: {}, + output: [ + { type: "text", text: JSON.stringify({ id: recordingId, mimeType: "video/webm" }) }, + ], + }, + }); assert.deepEqual((yield* store.getThreadAttachmentIds(threadId)).toSorted(), [ "answer-document", "answer-image", "attachment:old", "message-file", + // Coder: agent preview recordings (#29853) are not carried, so the recording is not listed. ]); }).pipe(Effect.provide(layerDatabase)), ); diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts index e46ac34c8be61..985815bd73eb4 100644 --- a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts @@ -73,7 +73,7 @@ export function limitRecoveryCommand( text: "Continue where you left off.", attachments: [], dispatchMode: { type: "start_immediately" }, - createdBy: "user", + createdBy: "agent", creationSource: "server", }; } diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts index 6aba94b00b2d4..e475e083dda16 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts @@ -34,7 +34,7 @@ import * as EventSink from "../EventSink.ts"; import * as EventStore from "../EventStore.ts"; import * as LegacyV1ThreadImporter from "./LegacyV1ThreadImporter.ts"; import * as Orchestrator from "../Orchestrator.ts"; -import * as ProjectionMaintenance from "../ProjectionMaintenance.ts"; +import * as ProjectionReplay from "../testkit/ProjectionReplay.ts"; import * as ProjectionStore from "../ProjectionStore.ts"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts"; @@ -566,9 +566,7 @@ const layerBoot = (input: { const layerImporterProvided = LegacyV1ThreadImporter.layer.pipe( Layer.provide(Layer.mergeAll(layerStoresProvided, layerEventSinkProvided)), ); - const layerMaintenanceProvided = ProjectionMaintenance.layer.pipe( - Layer.provide(layerStoresProvided), - ); + const layerMaintenanceProvided = ProjectionReplay.layer.pipe(Layer.provide(layerStoresProvided)); const layerOrchestratorProvided = ProviderReplayHarness.layerWithRegistry( { name: input.name, @@ -642,7 +640,7 @@ describe("orchestration v2 legacy v1 cutover", () => { Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const projections = yield* ProjectionStore.ProjectionStoreV2; const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts index 87059b7c1d0ae..a50b3ed3c1910 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts @@ -14,15 +14,16 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; import * as Statement from "effect/sql/Statement"; -import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import { listLinkedPullRequestThreads } from "../../pullRequest/linkedThreads.ts"; import * as EventSink from "../EventSink.ts"; import * as EventStore from "../EventStore.ts"; import * as LegacyV1ThreadImporter from "./LegacyV1ThreadImporter.ts"; -import * as ProjectionMaintenance from "../ProjectionMaintenance.ts"; +import * as ProjectionReplay from "../testkit/ProjectionReplay.ts"; import * as ProjectionStore from "../ProjectionStore.ts"; -const layerDatabase = SqlitePersistence.layerMemory; +import { layerReaderDatabase, traceSqlRouting } from "../../persistence/testkit/SqliteRouting.ts"; + +const layerDatabase = layerReaderDatabase; const layerEventStoreProvided = EventStore.layer.pipe(Layer.provideMerge(layerDatabase)); const layerProjectionStoreProvided = ProjectionStore.layer.pipe(Layer.provideMerge(layerDatabase)); const layerStoresProvided = Layer.mergeAll( @@ -34,14 +35,14 @@ const layerEventSinkProvided = EventSink.layer.pipe(Layer.provide(layerStoresPro const layerImporterProvided = LegacyV1ThreadImporter.layer.pipe( Layer.provide(Layer.mergeAll(layerStoresProvided, layerEventSinkProvided)), ); -const layerProjectionMaintenanceProvided = ProjectionMaintenance.layer.pipe( +const layerProjectionReplayProvided = ProjectionReplay.layer.pipe( Layer.provide(layerStoresProvided), ); const layerTest = Layer.mergeAll( layerStoresProvided, layerEventSinkProvided, layerImporterProvided, - layerProjectionMaintenanceProvided, + layerProjectionReplayProvided, ); const seedPendingTranscripts = Effect.gen(function* () { @@ -80,6 +81,72 @@ const seedPendingTranscripts = Effect.gen(function* () { yield* importer.reconcileShells; }); +it.effect("routes shell selectors to readers while imports stay writable", () => + Effect.gen(function* () { + const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; + const sql = yield* SqlClient.SqlClient; + const { queries, trace } = traceSqlRouting(); + yield* seedPendingTranscripts.pipe(trace); + const selectors = queries.filter( + ({ query }) => + query.includes("FROM projection_threads AS thread") || + query.includes("FROM orchestration_v2_legacy_imports AS legacy_import") || + query.includes("FROM projection_thread_messages AS message"), + ); + assert.lengthOf(selectors, 6); + assert.deepEqual([...new Set(selectors.map((row) => row.connection))], ["reader"]); + assert.deepEqual(yield* importer.reconcileShells, { + importedThreadCount: 0, + importedMessageCount: 0, + }); + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + interaction_mode, created_at, updated_at + ) VALUES ( + 'thread:uncommitted', 'project:background', 'Uncommitted legacy title', + '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', 'default', + '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, is_streaming, created_at, updated_at + ) VALUES ( + 'message:uncommitted', 'thread:uncommitted', 'user', 'Uncommitted preview', 0, + '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z' + ) + `; + yield* sql` + UPDATE orchestration_v2_projection_threads + SET payload_json = json_remove(payload_json, '$.pinnedAt') + WHERE thread_id = 'thread:first' + `; + yield* sql` + UPDATE projection_threads SET pinned_at = '2026-01-01T00:00:00.000Z' + WHERE thread_id = 'thread:first' + `; + const result = yield* importer.reconcileShells; + assert.deepEqual(result, { importedThreadCount: 2, importedMessageCount: 1 }); + const projections = yield* ProjectionStore.ProjectionStoreV2; + const repaired = yield* projections.getThreadProjection(ThreadId.make("thread:first")); + assert.equal(DateTime.formatIso(repaired.thread.pinnedAt!), "2026-01-01T00:00:00.000Z"); + const imported = yield* projections.getThreadProjection( + ThreadId.make("thread:uncommitted"), + ); + assert.equal(imported.messages[0]?.text, "Uncommitted preview"); + assert.equal( + (yield* projections.getThreadProjection(ThreadId.make("thread:uncommitted"))).thread + .title, + "Uncommitted legacy title", + ); + }), + ); + }).pipe(Effect.provide(layerTest)), +); + it.effect( "keeps hydrating later transcripts after a defect and leaves the failed thread pending", () => @@ -220,7 +287,7 @@ it.layer(layerTest)("LegacyV1ThreadImporter", (it) => { Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const projections = yield* ProjectionStore.ProjectionStoreV2; const eventSink = yield* EventSink.EventSinkV2; const threadId = ThreadId.make("thread:legacy-import"); @@ -605,7 +672,7 @@ it.layer(layerTest)("LegacyV1ThreadImporter", (it) => { Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const projections = yield* ProjectionStore.ProjectionStoreV2; const eventSink = yield* EventSink.EventSinkV2; const threadId = ThreadId.make("thread:legacy-metadata-upgrade"); diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts index d82a9e80a23b5..8c134d3b57d71 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts @@ -394,7 +394,7 @@ const make = Effect.gen(function* () { AND message.role IN ('user', 'assistant') ORDER BY message.created_at DESC, message.message_id DESC LIMIT 1 - `; + `.pipe(NodeSqliteClient.readOnly); const latestUser = yield* sql` SELECT message.message_id, @@ -424,7 +424,7 @@ const make = Effect.gen(function* () { AND message.role = 'user' ORDER BY message.created_at DESC, message.message_id DESC LIMIT 1 - `; + `.pipe(NodeSqliteClient.readOnly); return [latestUser[0], latest[0]].filter( (message, index, selected): message is LegacyMessageRow => message !== undefined && @@ -476,7 +476,7 @@ const make = Effect.gen(function* () { OR json_type(projection.payload_json, '$.branchPullRequest') IS NULL OR json_type(projection.payload_json, '$.activeOrderKey') IS NULL ORDER BY thread.created_at ASC, thread.thread_id ASC - `; + `.pipe(NodeSqliteClient.readOnly); let repairedThreadCount = 0; for (const row of repairRows) { const decoded = decodeStoredThread(row.payload_json); @@ -574,7 +574,7 @@ const make = Effect.gen(function* () { AND event.event_type = 'thread.created' ) ORDER BY thread.created_at ASC, thread.thread_id ASC - `; + `.pipe(NodeSqliteClient.readOnly); let importedThreadCount = repairedThreadCount; let importedMessageCount = 0; for (const row of rows) { diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 0b6070f364662..6ab0191649490 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -8,6 +8,7 @@ import { assert, describe, it } from "@effect/vitest"; import { PULL_REQUEST_WATCH_WAKE_LIMIT, evaluatePullRequestWatch as evaluate, + pullRequestWatchMessage, } from "./pullRequestWatch.ts"; // These cases read the head once or at one moment, so the no-checks notice never comes due. @@ -335,10 +336,30 @@ describe("evaluatePullRequestWatch", () => { assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), comments).changes, []); }); + it("cuts a long remark in the wake message without splitting an emoji", () => { + const body = `${"x".repeat(196)}🚀 and more`; + const report = evaluatePullRequestWatch(watch(), detail(), [ + remark("reviewer", "2026-10-02T12:06:00Z", body), + ]); + const { text } = pullRequestWatchMessage({ + number: 1, + url: "https://github.com/o/r/pull/1", + baseBranch: "main", + headSha: null, + report, + }); + assert.isTrue(text.isWellFormed()); + assert.include(text, `"${"x".repeat(196)}..."`); + }); + it("reports a conflict once, until the branch is clean again", () => { const conflicting = detail({ mergeability: "conflicting" }); const first = evaluatePullRequestWatch(watch(), conflicting, noRemarks); assert.deepEqual(first.changes, [{ kind: "conflicting" }]); + const { headSha: _unreadHead, ...headUnread } = conflicting; + const unread = evaluatePullRequestWatch(first.next, headUnread, noRemarks); + assert.deepEqual(unread.changes, []); + assert.deepEqual(evaluatePullRequestWatch(unread.next, conflicting, noRemarks).changes, []); const recomputing = evaluatePullRequestWatch( first.next, detail({ mergeability: "unknown" }), @@ -352,6 +373,20 @@ describe("evaluatePullRequestWatch", () => { assert.deepEqual(evaluatePullRequestWatch(clean.next, conflicting, noRemarks).changes, [ { kind: "conflicting" }, ]); + const pushed = { headSha: "bbbbbbbbbb" }; + const recomputed = evaluatePullRequestWatch( + first.next, + detail({ ...pushed, mergeability: "unknown" }), + noRemarks, + ); + assert.deepEqual( + evaluatePullRequestWatch( + recomputed.next, + detail({ ...pushed, mergeability: "conflicting" }), + noRemarks, + ).changes, + [{ kind: "conflicting" }], + ); }); it("does not spend the comment wake limit on check results", () => { diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 9577f9271c730..62f4daeb78312 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -41,7 +41,8 @@ export function evaluatePullRequestWatch( now: number, ): PullRequestWatchReport { const changes: Array = []; - const headSha = detail.headSha ?? null; + // Azure DevOps omits the head when its iteration read fails; that is not a push. + const headSha = detail.headSha ?? watch.headSha; const headMoved = headSha !== watch.headSha; // Hosts can return no checks on read failure; preserve prior state. @@ -126,12 +127,14 @@ export function evaluatePullRequestWatch( ...atLatest.map((remark) => remark.id), ]; - if (detail.mergeability === "conflicting" && !watch.conflicting) { + // Like a failed check, a conflict a push did not resolve is news for the new head. + const toldConflict = !headMoved && watch.conflicting; + if (detail.mergeability === "conflicting" && !toldConflict) { changes.push({ kind: "conflicting" }); } // "unknown" is a host still computing after a push; only a clean answer clears a conflict. const conflicting = - detail.mergeability === "unknown" ? watch.conflicting : detail.mergeability === "conflicting"; + detail.mergeability === "unknown" ? toldConflict : detail.mergeability === "conflicting"; const commentsOnly = changes.length > 0 && changes.every((change) => change.kind === "remarks"); const progress = headMoved || (changes.length > 0 && !commentsOnly); @@ -160,7 +163,11 @@ function snippet(body: string): string { .replaceAll(//g, " ") .replaceAll(/\s+/g, " ") .trim(); - return text.length <= SNIPPET_LENGTH ? text : `${text.slice(0, SNIPPET_LENGTH - 3)}...`; + if (text.length <= SNIPPET_LENGTH) return text; + // Half an emoji serializes to a lone surrogate escape, which provider JSON parsers reject. + const last = text.charCodeAt(SNIPPET_LENGTH - 4); + const end = last >= 0xd800 && last <= 0xdbff ? SNIPPET_LENGTH - 4 : SNIPPET_LENGTH - 3; + return `${text.slice(0, end)}...`; } function listed(items: ReadonlyArray, line: (item: T) => string): Array { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 8d4c6195e6139..f34e06e62451f 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1,7 +1,9 @@ +import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import { SourceControlProviderKind } from "@t3tools/contracts"; import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; @@ -66,7 +68,8 @@ import * as EffectWorker from "./EffectWorker.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as EventSink from "./EventSink.ts"; import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryService.ts"; -import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; +import * as EventStore from "./EventStore.ts"; +import * as ProjectionReplay from "./testkit/ProjectionReplay.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts"; import { PULL_REQUEST_WATCH_WAKE_LIMIT } from "./pullRequestWatch.ts"; @@ -89,7 +92,7 @@ import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const layerPlatformTest = Layer.merge( - NodeServices.layer, + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ resolveLink: () => Effect.die("unused title link"), }), @@ -273,8 +276,21 @@ const moveProject = (projectId: ProjectId, workspaceRoot: string, updatedAt: str }), ); +// Read-only oracle: projections still equal a replay of these unpruned test logs. +const layerProjectionReplayTest = ProjectionReplay.layer.pipe( + Layer.provide( + Layer.merge( + EventStore.layerFromOrchestrationEventStore.pipe( + Layer.provide(RuntimeLayer.layerEventInfrastructure), + ), + ProjectionStore.layer, + ), + ), +); + const layerTest = Layer.mergeAll( RuntimeLayer.layer, + layerProjectionReplayTest, RuntimeLayer.layerEventSink, ProjectStore.layer, ProjectionStore.layer, @@ -293,7 +309,7 @@ const layerTest = Layer.mergeAll( Layer.provide(layerPlatformTest), ); -const layerLegacyImportTest = RuntimeLayer.layer.pipe( +const layerLegacyImportTest = Layer.merge(RuntimeLayer.layer, layerProjectionReplayTest).pipe( Layer.provide(McpSessionRegistryTestkit.layer), Layer.provide(McpProviderSessions.layer), Layer.provideMerge(SqlitePersistence.layerMemory), @@ -808,6 +824,7 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i }); const layerSharedApplicationDataPlaneTest = Layer.mergeAll( + layerProjectionReplayTest, RuntimeLayer.layer.pipe(Layer.provide(RuntimeLayer.layerProjectService)), RuntimeLayer.layerProjectService, RuntimeLayer.layerEventSink, @@ -1038,7 +1055,9 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { (yield* outbox.listByCommandId(commandId)).map((effect) => effect.request.type), ["provider-thread.rollback"], ); - const path = yield* Path.Path.pipe(Effect.provide(NodeServices.layer)); + const path = yield* Path.Path.pipe( + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ); yield* orchestrator.dispatch({ type: "thread.create", createdBy: "user", @@ -2199,7 +2218,7 @@ it.layer(layerLegacyImportTest)("OrchestrationV2 legacy import", (it) => { Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const orchestrator = yield* Orchestrator.OrchestratorV2; const threadManagement = yield* ThreadManagementService.ThreadManagementService; const metadataThreadId = ThreadId.make("runtime-layer-legacy-metadata-thread"); @@ -2633,6 +2652,20 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { ); assert.deepEqual(activeShell?.unsettledAt, activeProjection.thread.unsettledAt); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("runtime-layer-lifecycle-resettle"), + threadId, + }); + yield* orchestrator.dispatch({ + type: "thread.pin", + commandId: CommandId.make("runtime-layer-lifecycle-pin-settled"), + threadId, + }); + const pinnedProjection = yield* orchestrator.getThreadProjection(threadId); + assert.equal(pinnedProjection.thread.settledOverride, "active"); + assert.isNotNull(pinnedProjection.thread.unsettledAt); + const archive = yield* orchestrator.dispatch({ type: "thread.archive", commandId: CommandId.make("runtime-layer-lifecycle-archive"), @@ -2714,7 +2747,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { it.effect("keeps the branch pull request when linking another pull request", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const threadId = ThreadId.make("branch-pr-link"); const projectId = ProjectId.make("branch-pr-project"); yield* seedProject({ @@ -2805,7 +2838,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { it.effect("retains multiple pull requests and dismissed stack members through rebuilds", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const threadId = ThreadId.make("runtime-multiple-pull-requests"); yield* orchestrator.dispatch({ type: "thread.create", @@ -2896,7 +2929,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { it.effect("starts, records, and stops a pull request watch", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const threadId = ThreadId.make("runtime-pull-request-watch"); yield* orchestrator.dispatch({ type: "thread.create", @@ -2988,7 +3021,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { it.effect.each(["manual", "automatic"])("settling ends every pull request watch: %s", (mode) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const threadId = ThreadId.make(`pr-watch-settle-${mode}`); yield* orchestrator.dispatch({ type: "thread.create", @@ -3147,63 +3180,156 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect("ends a watch it cannot read, and tells the agent", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadId = ThreadId.make("runtime-pull-request-watch-unreadable"); - const projectId = ProjectId.make("pr-watch-unreadable-project"); - yield* seedProject({ - projectId, - title: "Watch unreadable", - workspaceRoot: "/workspace/watch-unreadable", - defaultModelSelection: null, - createdAt: "2026-10-01T00:00:00.000Z", - }); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("pr-watch-unreadable-create"), - threadId, - projectId, - title: "Watch unreadable", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - yield* orchestrator.dispatch({ - type: "thread.pull-request.watch", - commandId: CommandId.make("pr-watch-unreadable-start"), - threadId, - host: "github.com", - repository: "pingdotgg/t3code", - number: 8, - watching: true, - link: { url: "https://github.com/pingdotgg/t3code/pull/8", source: "agent" }, - }); - const reactor = yield* PullRequestWatchReactor.make.pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(PullRequestService.PullRequestService)({ - detail: () => Effect.die("host unreachable"), - activity: () => Effect.die("host unreachable"), + it.effect.each([ + { reason: "failed", activityFails: false }, + { reason: "not-found", activityFails: false }, + { reason: "failed", activityFails: true }, + { reason: "not-found", activityFails: true }, + { reason: "forbidden", activityFails: true }, + ] as const)( + "handles repeated host read failures: $reason (activity fails: $activityFails)", + ({ reason, activityFails }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make( + `runtime-pull-request-watch-unreadable-${reason}-${activityFails}`, + ); + const projectId = ProjectId.make(`pr-watch-unreadable-project-${reason}-${activityFails}`); + yield* seedProject({ + projectId, + title: "Watch unreadable", + workspaceRoot: "/workspace/watch-unreadable", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`pr-watch-unreadable-create-${reason}-${activityFails}`), + threadId, + projectId, + title: "Watch unreadable", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-unreadable-start-${reason}-${activityFails}`), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 8, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/8", source: "agent" }, + }); + let disconnected = false; + let passed = false; + let genericFailure = false; + const activityFailures = yield* Queue.unbounded(); + const readFailure = (operation: string, failure: "failed" | "not-found" | "forbidden") => + new PullRequestOperationError({ + operation, + detail: "host read failed", + cause: new PullRequestProviderError({ + provider: SourceControlProviderKind.make("github"), + operation, + reason: failure, + detail: "host read failed", }), + }); + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(PullRequestService.PullRequestService)({ + watchFingerprint: () => Effect.succeed(null), + detail: () => + genericFailure + ? Effect.die("host read failed") + : disconnected + ? (activityFails ? Queue.take(activityFailures) : Effect.void).pipe( + Effect.andThen(Effect.fail(readFailure("detail", reason))), + ) + : Effect.succeed({ + ...watchedPullRequestDetail({ + projectId, + number: 8, + at: "2026-10-02T12:00:00.000Z", + }), + headSha: "abc1234def", + checks: [ + { + name: "test", + status: passed ? ("success" as const) : ("pending" as const), + description: null, + url: null, + required: true, + }, + ], + }), + activity: () => + disconnected && activityFails + ? Queue.offer(activityFailures, undefined).pipe( + Effect.andThen(Effect.fail(readFailure("activity", "failed"))), + ) + : Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ), ), - ), - ); - for (let pass = 0; pass < 15; pass += 1) yield* reactor.sweep; + ); + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const sweep = reactor.sweep.pipe(Effect.andThen(worker.drain())); + const summaries = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), + ); + yield* sweep; + assert.deepEqual(yield* summaries, []); + disconnected = true; + for (let pass = 0; pass < 15; pass += 1) { + genericFailure = reason === "failed" && (pass === 0 || (pass >= 2 && pass <= 8)); + yield* TestClock.adjust("2 minutes"); + yield* sweep; + } - const thread = yield* orchestrator.getThreadShell(threadId); - assert.isUndefined(thread?.pullRequests?.[0]?.watch); - const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); - assert.deepEqual( - messages.flatMap((message) => message.notification?.summary ?? []), - ["!8: stopped watching, could not read it"], - ); - }), + const thread = yield* orchestrator.getThreadShell(threadId); + if (reason !== "failed") { + assert.isUndefined(thread?.pullRequests?.[0]?.watch); + assert.deepEqual(yield* summaries, ["!8: stopped watching, could not read it"]); + return; + } + assert.isDefined(thread?.pullRequests?.[0]?.watch); + assert.deepEqual(yield* summaries, []); + disconnected = false; + genericFailure = false; + passed = true; + yield* sweep; + assert.deepEqual(yield* summaries, ["!8: checks passed"]); + yield* TestClock.adjust("10 minutes"); + yield* sweep; + assert.deepEqual(yield* summaries, ["!8: checks passed"]); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-unreadable-stop-${reason}-${activityFails}`), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 8, + watching: false, + }); + yield* worker.drain(); + }), ); it.effect("wakes once for a state it reported, across a restart and a new watch", () => @@ -3248,6 +3374,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { let headSha = "ad70160aaa"; const host = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(PullRequestService.PullRequestService)({ detail: () => Effect.sync(() => ({ @@ -3346,6 +3473,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { let remainingStatus: "pending" | "success" = "pending"; const host = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(PullRequestService.PullRequestService)({ detail: () => Effect.sync(() => ({ @@ -3490,6 +3618,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { ); const host = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(PullRequestService.PullRequestService)({ detail: () => Effect.sync(() => ({ @@ -3588,6 +3717,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { let checks: PullRequestDetail["checks"] = []; const host = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(PullRequestService.PullRequestService)({ detail: () => Effect.sync(() => ({ @@ -3691,6 +3821,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { let checks: PullRequestDetail["checks"] = []; const host = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(PullRequestService.PullRequestService)({ detail: () => Effect.sync(() => ({ @@ -3805,6 +3936,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { let reads = 0; const host = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(PullRequestService.PullRequestService)({ // The host reports the same fingerprint throughout, so only the deadline can prompt a read. watchFingerprint: () => @@ -4028,6 +4160,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { Effect.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.mock(PullRequestService.PullRequestService)({ detail: () => Effect.succeed(detail), activity: () => @@ -4624,7 +4757,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { it.effect("cancels preparing work on archive and keeps it cancelled through replay", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const threadId = ThreadId.make("runtime-layer-archive-preparing-thread"); yield* orchestrator.dispatch({ type: "thread.create", @@ -6152,13 +6285,18 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect.each(["startup", "shutdown"] as const)( - "preserves and holds queued messages across %s until explicitly resumed", - (trigger) => + it.effect.each([ + { trigger: "startup", started: true }, + { trigger: "shutdown", started: true }, + { trigger: "startup", started: false }, + { trigger: "shutdown", started: false }, + ] as const)( + "preserves and holds queued messages across $trigger with started=$started until explicitly resumed", + ({ trigger, started }) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const recovery = yield* ProviderRuntimeRecoveryService.ProviderRuntimeRecoveryService; - const threadId = ThreadId.make(`queue-hold-${trigger}`); + const threadId = ThreadId.make(`queue-hold-${trigger}-${started}`); yield* orchestrator.dispatch({ type: "thread.create", createdBy: "user", @@ -6188,22 +6326,57 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }); } const before = yield* orchestrator.getThreadProjection(threadId); + const source = before.runs[0]!; + if (started) { + yield* (yield* EventSink.EventSinkV2).write({ + events: [ + { + id: EventId.make(`${threadId}:started`), + type: "run.updated", + threadId, + occurredAt: yield* DateTime.now, + payload: { ...source, status: "running", startedAt: yield* DateTime.now }, + }, + ], + }); + } const queued = before.runs.filter((run) => run.status === "queued"); assert.equal(queued.length, 2); yield* recovery.reconcile(trigger); yield* recovery.reconcile("startup"); - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; assert.isTrue((yield* maintenance.rebuild).valid); assert.equal(yield* orchestrator.resumeQueuedRuns, 0); const held = yield* orchestrator.getThreadProjection(threadId); assert.deepEqual( held.runs.map((run) => run.status), - ["cancelled", "queued", "queued"], + [started ? "cancelled" : "queued", "queued", "queued"], ); - for (const run of queued) { + if (!started) { + assert.deepInclude( + held.runs.find((run) => run.id === source.id), + { + status: "queued", + queueHeld: true, + queuePosition: 1, + }, + ); + } + if (!started) { + const prompt = held.turnItems.find( + (item) => item.type === "user_message" && item.messageId === source.userMessageId, + ); + assert.deepInclude(prompt, { + inputIntent: "queued_turn", + status: "pending", + startedAt: null, + completedAt: null, + }); + } + for (const [index, run] of queued.entries()) { assert.deepEqual( held.runs.find((row) => row.id === run.id), - { ...run, queueHeld: true }, + { ...run, queueHeld: true, ...(started ? {} : { queuePosition: index + 2 }) }, ); assert.deepEqual( held.messages.find((row) => row.id === run.userMessageId), @@ -6216,21 +6389,23 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { assert.equal(held.nodes.find((row) => row.id === run.rootNodeId)?.status, "pending"); } const first = queued[0]!; - const second = queued[1]!; - yield* orchestrator.dispatch({ - type: "queued-run.edit", - commandId: CommandId.make(`${threadId}:edit`), - threadId, - runId: second.id, - text: "Edited second message", - }); - yield* orchestrator.dispatch({ - type: "queued-run.reorder", - commandId: CommandId.make(`${threadId}:reorder`), - threadId, - runId: second.id, - beforeRunId: first.id, - }); + const second = started ? queued[1]! : source; + if (started) { + yield* orchestrator.dispatch({ + type: "queued-run.edit", + commandId: CommandId.make(`${threadId}:edit`), + threadId, + runId: second.id, + text: "Edited second message", + }); + yield* orchestrator.dispatch({ + type: "queued-run.reorder", + commandId: CommandId.make(`${threadId}:reorder`), + threadId, + runId: second.id, + beforeRunId: first.id, + }); + } assert.equal(yield* orchestrator.resumeQueuedRuns, 0); const resume = { type: "queue.resume" as const, @@ -6245,9 +6420,41 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { assert.isFalse(resumed.runs.some((run) => run.status === "queued" && run.queueHeld)); assert.equal( resumed.messages.find((row) => row.id === second.userMessageId)?.text, - "Edited second message", + started ? "Edited second message" : "Active", + ); + if (!started) { + assert.equal( + resumed.attempts.find((row) => row.id === source.activeAttemptId)?.status, + "pending", + ); + assert.equal( + resumed.nodes.find((row) => row.id === source.rootNodeId)?.status, + "pending", + ); + // Recovery command identities include the restart timestamp. + yield* TestClock.adjust("1 millis"); + yield* recovery.reconcile("startup"); + yield* orchestrator.dispatch({ + ...resume, + commandId: CommandId.make(`${threadId}:resume-again`), + }); + const starts = yield* (yield* EffectOutbox.EffectOutboxV2).listByCommandId( + CommandId.make(`command:system:start-queued:${source.id}`), + ); + const pendingStarts = starts.filter((effect) => effect.status === "pending"); + assert.lengthOf(pendingStarts, 1); + assert.deepEqual(pendingStarts[0]?.request, { + type: "provider-turn.start", + runId: source.id, + }); + } + const afterRetries = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterRetries.runs.length, + 3, + "resume retries must not duplicate messages or runs", ); - assert.equal(resumed.runs.length, 3, "resume retries must not duplicate messages or runs"); + assert.lengthOf(afterRetries.messages, before.messages.length); }), ); @@ -6764,7 +6971,7 @@ const layerScratchPromotionTest = ScratchThreadPromotion.layer.pipe( namedProjectsRoot: "/projects", }), ), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(layerProjectDeletionTest), ); @@ -7418,11 +7625,11 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("thread opens", (it) ); }); -it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", (it) => { +it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("PR link events", (it) => { it.effect("persists only the changed PR link and replays it without losing other links", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const sql = yield* SqlClient.SqlClient; const threadId = ThreadId.make("compact-pr-sync"); yield* orchestrator.dispatch({ @@ -7521,85 +7728,6 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", (yield* orchestrator.getThreadShell(threadId))?.pullRequests, after?.pullRequests, ); - for (const [commandId, title] of [ - ["compact-pr-sync-again", "Still open PR"], - ["compact-pr-sync-merged", "Merged PR"], - ] as const) { - yield* orchestrator.dispatch({ - type: "thread.pull-request-link.sync", - commandId: CommandId.make(commandId), - threadId, - ...key, - number: 2, - snapshot: { - state: title === "Merged PR" ? "merged" : "open", - title, - headBranch: "feature/pr", - baseBranch: "main", - isDraft: false, - updatedAt: null, - syncedAt: "2026-10-13T13:00:00.000Z", - }, - stack: null, - }); - } - const latest = yield* orchestrator.getThreadShell(threadId); - yield* maintenance.compactEventStore(); - const linkEvents = yield* sql<{ command_id: string }>` - SELECT command_id FROM orchestration_events - WHERE stream_id = ${threadId} AND event_type = 'thread.pull-request-link-updated' - ORDER BY sequence - `; - assert.deepEqual( - linkEvents.map((row) => row.command_id), - ["compact-pr-sync", "compact-pr-sync-merged"], - ); - assert.isTrue((yield* maintenance.rebuild).valid); - assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pullRequests, - latest?.pullRequests, - ); - - // Visits preserve updatedAt, so compaction must retain earlier renames; later renames supersede visits. - const threadEvents = () => sql<{ event_type: string }>` - SELECT event_type FROM orchestration_events - WHERE stream_id = ${threadId} - AND event_type IN ('thread.metadata-updated', 'thread.visited') - ORDER BY sequence - `; - yield* orchestrator.dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make("compact-pr-rename"), - threadId, - title: "Renamed before visit", - }); - for (const [commandId, visitedAt] of [ - ["compact-pr-visit", "2026-10-13T14:00:00.000Z"], - ["compact-pr-visit-again", "2026-10-13T15:00:00.000Z"], - ] as const) { - yield* orchestrator.dispatch({ - type: "thread.visit", - commandId: CommandId.make(commandId), - threadId, - visitedAt, - }); - } - yield* maintenance.compactEventStore(); - assert.deepEqual( - (yield* threadEvents()).map((row) => row.event_type), - ["thread.metadata-updated", "thread.visited"], - ); - yield* orchestrator.dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make("compact-pr-rename-again"), - threadId, - title: "Renamed after visit", - }); - yield* maintenance.compactEventStore(); - assert.deepEqual( - (yield* threadEvents()).map((row) => row.event_type), - ["thread.metadata-updated"], - ); }), ); }); @@ -8318,6 +8446,13 @@ it.layer(layerTest)("usage-limit recovery", (it) => { yield* orchestrator.dispatch(resume); } const after = yield* orchestrator.getThreadProjection(threadId); + if (scenario === "resume") { + assert.deepInclude(after.messages.at(-1), { + text: "Continue where you left off.", + createdBy: "agent", + creationSource: "server", + }); + } assert.lengthOf( after.runs, before.runs.length + diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts index b29628d1ae11b..acaa3d41baa53 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -201,7 +202,7 @@ describe("orchestrator replay recovery", () => { }; const options = { databaseLayer: SqlitePersistence.layerFromPath(dbPath).pipe( - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), }; @@ -251,7 +252,9 @@ describe("orchestrator replay recovery", () => { assert.lengthOf(projection.providerThreads, 1); }).pipe( provideDeterministicTestRuntime, - Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer)), + Effect.provide( + Layer.merge(IdAllocator.layer, Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), ), ), ); @@ -284,7 +287,7 @@ describe("orchestrator replay recovery", () => { const { harness, assertComplete } = makeClaudeRestartReplayHarness(transcript); const options = { databaseLayer: SqlitePersistence.layerFromPath(path.join(tempDir, "state.sqlite")).pipe( - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), }; @@ -364,7 +367,9 @@ describe("orchestrator replay recovery", () => { ); }).pipe( provideDeterministicTestRuntime, - Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer)), + Effect.provide( + Layer.merge(IdAllocator.layer, Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), ), ), ); @@ -396,7 +401,7 @@ describe("orchestrator replay recovery", () => { const { harness, assertComplete } = makeClaudeRestartReplayHarness(transcript); const options = { databaseLayer: SqlitePersistence.layerFromPath(path.join(tempDir, "state.sqlite")).pipe( - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), }; const childThreadOf = (result: OrchestratorV2ScenarioResult, parentThreadId: ThreadId) => @@ -450,7 +455,7 @@ describe("orchestrator replay recovery", () => { harness, { databaseLayer: SqlitePersistence.layerFromPath(snapshotPath).pipe( - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), recoverOnStartup: true, continueThreadsAfterServerUpdate: false, @@ -485,7 +490,9 @@ describe("orchestrator replay recovery", () => { ); }).pipe( provideDeterministicTestRuntime, - Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer)), + Effect.provide( + Layer.merge(IdAllocator.layer, Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), ), ), ); diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts index 4adc098ba6d7b..ff901ee361088 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { EventId, ProviderDriverKind, type ProviderReplayEntry } from "@t3tools/contracts"; @@ -158,7 +159,7 @@ const runRestart = Effect.fn("runRestart")(function* (input: { const phase2Steps = materialized.steps.slice(firstIdle + 1); const { harness, assertComplete } = makeClaudeRestartReplayHarness(transcript); const layerDatabase = SqlitePersistence.layerFromPath(path.join(tempDir, "state.sqlite")).pipe( - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const snapshotPath = path.join(tempDir, "restart-state.sqlite"); const scenario = (name: string, steps: typeof materialized.steps) => ({ @@ -217,7 +218,7 @@ const runRestart = Effect.fn("runRestart")(function* (input: { ), ); const snapshotDatabase = SqlitePersistence.layerFromPath(snapshotPath).pipe( - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const settled = projectionFor(before, SCENARIO); assert.equal(settled.runs[0]?.status, "completed"); @@ -292,7 +293,9 @@ describe("restart-cancelled background work", () => { ]); }).pipe( provideDeterministicTestRuntime, - Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer)), + Effect.provide( + Layer.merge(IdAllocator.layer, Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), ), ), ); diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index a3151462bbe00..b413c0aefa259 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -75,6 +75,10 @@ export type OrchestratorV2ScenarioStep = readonly threadId: ThreadId; readonly runId: OrchestrationV2Run["id"]; readonly itemType: OrchestrationV2TurnItem["type"]; + /** Wait until the item also has this status. */ + readonly itemStatus?: OrchestrationV2TurnItem["status"]; + /** Look in the threads of the run's provider subagents instead of the run itself. */ + readonly inSubagentThreads?: boolean; } | { readonly type: "release_replay_gate_after_waiting"; @@ -446,6 +450,8 @@ export function runOrchestratorV2Scenario( threadId: ThreadId, runId: OrchestrationV2Run["id"], itemType: OrchestrationV2TurnItem["type"], + itemStatus: OrchestrationV2TurnItem["status"] | undefined, + inSubagentThreads: boolean, attemptsRemaining = SCENARIO_WAIT_ATTEMPTS, deadlineAt = scenarioWaitDeadline(), ): Effect.Effect< @@ -455,16 +461,21 @@ export function runOrchestratorV2Scenario( > => Effect.gen(function* () { const projection = yield* orchestrator.getThreadProjection(threadId); - const hasTurnItem = projection.turnItems.some( - (item) => item.runId === runId && item.type === itemType, - ); - if (hasTurnItem) { - return; + const matches = (item: OrchestrationV2TurnItem) => + item.type === itemType && (itemStatus === undefined || item.status === itemStatus); + if (!inSubagentThreads) { + if (projection.turnItems.some((item) => item.runId === runId && matches(item))) return; + } else { + for (const subagent of projection.subagents) { + if (subagent.runId !== runId || subagent.childThreadId === null) continue; + const child = yield* orchestrator.getThreadProjection(subagent.childThreadId); + if (child.turnItems.some(matches)) return; + } } if (scenarioWaitExhausted(attemptsRemaining, deadlineAt)) { return yield* new OrchestratorV2ScenarioStepError({ scenario: scenario.name, - step: `await_run_turn_item:${runId}:${itemType}`, + step: `await_run_turn_item:${runId}:${itemType}${itemStatus === undefined ? "" : `:${itemStatus}`}`, }); } yield* yieldToRuntime; @@ -472,6 +483,8 @@ export function runOrchestratorV2Scenario( threadId, runId, itemType, + itemStatus, + inSubagentThreads, attemptsRemaining - 1, deadlineAt, ); @@ -661,7 +674,13 @@ export function runOrchestratorV2Scenario( yield* finishHeldRun(step); break; case "await_run_turn_item": - yield* waitForRunTurnItem(step.threadId, step.runId, step.itemType); + yield* waitForRunTurnItem( + step.threadId, + step.runId, + step.itemType, + step.itemStatus, + step.inSubagentThreads === true, + ); break; case "release_replay_gate_after_waiting": yield* releaseReplayGateAfterWaiting(step.label, step.threadId, step.runId); diff --git a/apps/server/src/orchestration-v2/testkit/ProjectionReplay.ts b/apps/server/src/orchestration-v2/testkit/ProjectionReplay.ts new file mode 100644 index 0000000000000..437db15c375f8 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/ProjectionReplay.ts @@ -0,0 +1,182 @@ +import { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; + +import * as EventStore from "../EventStore.ts"; +import * as ProjectionStore from "../ProjectionStore.ts"; + +export interface ProjectionReplayVerification { + readonly valid: boolean; + readonly schemaVersion: number; + readonly expectedSequence: number; + readonly projectionSequence: number; + readonly unreadableThreadIds: ReadonlyArray; + readonly missingThreadIds: ReadonlyArray; + readonly unexpectedThreadIds: ReadonlyArray; +} + +export class ProjectionReplayError extends Schema.TaggedError()( + "ProjectionReplayError", + { cause: Schema.Defect() }, +) {} + +/** + * Test oracle: projections must equal a replay of an unpruned event log. Production prunes + * events the projections cover, so it never replays (see ProjectionMaintenance). + */ +export class ProjectionReplay extends Context.Service< + ProjectionReplay, + { + readonly verify: Effect.Effect; + readonly rebuild: Effect.Effect; + } +>()("t3/orchestration-v2/testkit/ProjectionReplay") {} + +type ProjectionMetadataRow = { + readonly schema_version: number; + readonly last_sequence: number; +}; + +export const layer: Layer.Layer< + ProjectionReplay, + never, + EventStore.EventStoreV2 | ProjectionStore.ProjectionStoreV2 | SqlClient.SqlClient +> = Layer.effect( + ProjectionReplay, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const eventStore = yield* EventStore.EventStoreV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + + const verify = Effect.gen(function* () { + const expectedThreadRows = yield* sql<{ readonly thread_id: string }>` + SELECT DISTINCT stream_id AS thread_id + FROM orchestration_events INDEXED BY orchestration_events_v2_created_threads_idx + WHERE application_event_version = 2 + AND aggregate_kind = 'thread' + AND event_type = 'thread.created' + ORDER BY stream_id ASC + `; + const projectionRows = yield* sql<{ readonly thread_id: string }>` + SELECT thread_id + FROM orchestration_v2_projection_threads + ORDER BY thread_id ASC + `; + const actualIds = projectionRows.map((row) => ThreadId.make(row.thread_id)); + const expectedIds = expectedThreadRows.map((row) => ThreadId.make(row.thread_id)); + const actualSet = new Set(actualIds); + const expectedSet = new Set(expectedIds); + const missingThreadIds = expectedIds.filter((threadId) => !actualSet.has(threadId)); + const unexpectedThreadIds = actualIds.filter((threadId) => !expectedSet.has(threadId)); + const unreadableThreadIds = yield* projectionStore.getUnreadableThreadIds(); + const metadata = yield* sql` + SELECT schema_version, last_sequence + FROM orchestration_v2_projection_metadata + WHERE projection_name = 'thread-projections' + LIMIT 1 + `; + const expectedSequence = yield* eventStore.latestSequence(); + const schemaVersion = metadata[0]?.schema_version ?? 0; + const projectionSequence = metadata[0]?.last_sequence ?? 0; + return { + valid: + schemaVersion === ProjectionStore.ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION && + projectionSequence === expectedSequence && + missingThreadIds.length === 0 && + unexpectedThreadIds.length === 0 && + unreadableThreadIds.length === 0, + schemaVersion, + expectedSequence, + projectionSequence, + unreadableThreadIds, + missingThreadIds, + unexpectedThreadIds, + } satisfies ProjectionReplayVerification; + }).pipe(sql.withTransaction); + + const rebuild = Effect.gen(function* () { + yield* sql.withTransaction( + Effect.gen(function* () { + const throughSequence = yield* eventStore.latestSequence(); + yield* sql`DELETE FROM orchestration_v2_projection_context_transfers`; + yield* sql`DELETE FROM orchestration_v2_projection_context_handoffs`; + yield* sql`DELETE FROM orchestration_v2_projection_checkpoints`; + yield* sql`DELETE FROM orchestration_v2_projection_checkpoint_scopes`; + yield* sql`DELETE FROM orchestration_v2_projection_turn_items`; + yield* sql`DELETE FROM orchestration_v2_projection_plans`; + yield* sql`DELETE FROM orchestration_v2_projection_messages`; + yield* sql`DELETE FROM orchestration_v2_projection_runtime_requests`; + yield* sql`DELETE FROM orchestration_v2_projection_provider_turns`; + yield* sql`DELETE FROM orchestration_v2_projection_provider_threads`; + yield* sql`DELETE FROM orchestration_v2_projection_provider_session_bindings`; + yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions`; + yield* sql`DELETE FROM orchestration_v2_projection_subagents`; + yield* sql`DELETE FROM orchestration_v2_projection_nodes`; + yield* sql`DELETE FROM orchestration_v2_projection_run_attempts`; + yield* sql`DELETE FROM orchestration_v2_projection_runs`; + yield* sql`DELETE FROM orchestration_v2_projection_threads`; + yield* sql`DELETE FROM orchestration_v2_turn_item_positions`; + + const pageSize = 500; + let lastSequence = 0; + while (true) { + const page = yield* eventStore + .read({ afterSequence: lastSequence, throughSequence, limit: pageSize }) + .pipe(Stream.runCollect); + for (const stored of page) { + yield* projectionStore.apply(stored.event); + if (stored.event.type === "turn-item.updated") { + yield* sql` + INSERT INTO orchestration_v2_turn_item_positions ( + thread_id, + turn_item_id, + ordinal + ) + VALUES ( + ${stored.event.threadId}, + ${stored.event.payload.id}, + ${stored.event.payload.ordinal} + ) + ON CONFLICT(thread_id, turn_item_id) DO UPDATE SET + ordinal = excluded.ordinal + `; + } + lastSequence = stored.sequence; + } + if (page.length < pageSize) break; + yield* Effect.yieldNow; + } + const now = DateTime.formatIso(yield* DateTime.now); + yield* sql` + INSERT INTO orchestration_v2_projection_metadata ( + projection_name, + schema_version, + last_sequence, + updated_at + ) + VALUES ( + 'thread-projections', + ${ProjectionStore.ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION}, + ${lastSequence}, + ${now} + ) + ON CONFLICT(projection_name) DO UPDATE SET + schema_version = excluded.schema_version, + last_sequence = excluded.last_sequence, + updated_at = excluded.updated_at + `; + }), + ); + return yield* verify; + }); + + const mapError = (effect: Effect.Effect) => + effect.pipe(Effect.mapError((cause) => new ProjectionReplayError({ cause }))); + return ProjectionReplay.of({ verify: mapError(verify), rebuild: mapError(rebuild) }); + }), +); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayFixtures.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayFixtures.ts index bc829b611a8b5..149b997a94e1f 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayFixtures.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayFixtures.ts @@ -164,7 +164,14 @@ const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* < const projection = result.projections.get(projectionThreadId); assert.isDefined(projection); const latestRun = projection.runs.at(-1); - assert.deepEqual(latestRun?.modelSelection, input.driver.modelSelection); + // A `set_model` step moves later runs to another model of the same instance. + const finalModel = fixtureInput.steps.findLast((step) => step.type === "set_model"); + assert.deepEqual( + latestRun?.modelSelection, + finalModel?.type === "set_model" + ? { ...input.driver.modelSelection, model: finalModel.model } + : input.driver.modelSelection, + ); if (projection.runs.some((run) => run.status === "completed")) { const threadStartCheckpoint = projection.checkpoints.find( (checkpoint) => checkpoint.ordinalWithinScope === 0 && checkpoint.appRunOrdinal === null, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index bf69d030d5062..b35d2d2998601 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 NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { ProviderDriverKind, ProviderReplayTranscript } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -251,8 +252,7 @@ export function layerWithRegistry( Layer.provide( Layer.mergeAll( Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ - // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. - getInstance: () => Effect.succeed(undefined), + getInstance: () => Effect.undefined, }), layerStores, layerEventSinkProvided, @@ -260,7 +260,7 @@ export function layerWithRegistry( ThreadCommandExecutor.layer, GitVcsDriver.layer.pipe( Layer.provide(layerServerConfig), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), ), @@ -268,11 +268,11 @@ export function layerWithRegistry( const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProcess.layer), Layer.provide(layerServerConfig), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const layerCheckpointStore = CheckpointStore.layer.pipe( Layer.provide(layerVcsDriverRegistry), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const layerCheckpointServiceProvided = CheckpointService.layer.pipe( Layer.provide(Layer.mergeAll(layerCheckpointStore, IdAllocator.layer)), @@ -449,7 +449,10 @@ export function layerWithRegistry( layerEventSinkProvided, layerContinuationWorkerProvided, layerCommandOutputHubProvided, - ).pipe(Layer.provide(ProviderTurnStartServiceTestkit.layer), Layer.provide(NodeServices.layer)); + ).pipe( + Layer.provide(ProviderTurnStartServiceTestkit.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ); if (options.runEffectWorker === false) { return layerReplayRuntime; diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 731dd47bf8f8c..b74af8f685572 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -52,7 +52,7 @@ import * as LegacyV1ThreadImporter from "../legacy/LegacyV1ThreadImporter.ts"; import * as Orchestrator from "../Orchestrator.ts"; import * as EffectWorker from "../EffectWorker.ts"; import * as EffectOutbox from "../EffectOutbox.ts"; -import * as ProjectionMaintenance from "../ProjectionMaintenance.ts"; +import * as ProjectionReplay from "./ProjectionReplay.ts"; import * as ProjectionStore from "../ProjectionStore.ts"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; @@ -119,6 +119,8 @@ function makeTestAdapter(input: { readonly subagentOnlyBackgroundWork?: boolean; readonly holdFirstTurn?: Deferred.Deferred; readonly releaseFirstTurn?: Deferred.Deferred; + readonly backgroundWork?: Ref.Ref; + readonly closedSessions?: Ref.Ref>; }): ProviderAdapter.ProviderAdapterV2["Service"] { return { instanceId: input.instanceId, @@ -128,6 +130,12 @@ function makeTestAdapter(input: { openSession: (sessionInput) => Effect.gen(function* () { const events = yield* PubSub.unbounded(); + if (input.closedSessions !== undefined) { + const closedSessions = input.closedSessions; + yield* Effect.addFinalizer(() => + Ref.update(closedSessions, (ids) => [...ids, sessionInput.providerSessionId]), + ); + } const now = yield* DateTime.now; const providerSession: OrchestrationV2ProviderSession = { id: sessionInput.providerSessionId, @@ -162,6 +170,14 @@ function makeTestAdapter(input: { ...(input.canReuseContextUsage === undefined ? {} : { canReuseContextUsage: input.canReuseContextUsage }), + ...(input.backgroundWork === undefined + ? {} + : { + // Like Claude: native subagents are visible only to the session-wide + // probe, never to the per-thread roster probe. + hasPendingBackgroundWork: Ref.get(input.backgroundWork), + hasPendingBackgroundWorkForThread: () => Effect.succeed(false), + }), ensureThread: (threadInput) => Effect.gen(function* () { const createdAt = yield* DateTime.now; @@ -748,16 +764,7 @@ describe("orchestration v2 provider switching", () => { yield* wait(3); } if (replaceNative) { - const target = (yield* orchestrator.getThreadProjection(threadId)).providerThreads.find( - (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, - )!; - yield* orchestrator.dispatch({ - type: "provider-session.detach", - commandId: CommandId.make("detach-for-native-replacement"), - threadId, - providerSessionId: target.providerSessionId!, - }); - yield* worker.drain(); + // Switching back to Codex already released Claude's session. yield* Ref.set(failResumeOnce, true); } if (scenario.includes("retry")) yield* Ref.set(failStartOnce, true); @@ -2520,6 +2527,246 @@ describe("orchestration v2 provider switching", () => { ), ); + // regression: #20343 sending to an instance with separate history must release the old session + it.live.each([ + [ + "detaches the previous instance's session when a send hands off to another instance", + { background: false, queued: false, sharedHistory: false }, + ], + [ + "refuses the hand-off while the previous instance still runs background work", + { background: true, queued: false, sharedHistory: false }, + ], + [ + "keeps a busy previous session running when a queued hand-off starts", + { background: true, queued: true, sharedHistory: false }, + ], + [ + "holds a queued account switch while the replaced session still runs background work", + { background: true, queued: true, sharedHistory: true }, + ], + ] as const)("%s", ([, { background, queued, sharedHistory }]) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("handoff-releases-session"); + const capturedTurns = yield* Ref.make>([]); + const backgroundWork = yield* Ref.make(background); + const closedSessions = yield* Ref.make>([]); + const firstStarted = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + const secondSelection: ModelSelection = { + ...CLAUDE_MODEL_SELECTION, + instanceId: ProviderInstanceId.make("claude-second-account"), + }; + const adapters = [ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: { 1: "First account complete" }, + capturedTurns, + backgroundWork, + closedSessions, + ...(queued ? { holdFirstTurn: firstStarted, releaseFirstTurn: releaseFirst } : {}), + }), + makeTestAdapter({ + instanceId: secondSelection.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: secondSelection, + responseByRunOrdinal: { 2: "Second account complete" }, + capturedTurns, + }), + ]; + // Without a shared continuation key, each Claude instance owns its own native history. + const layerRegistry = sharedHistory + ? Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistryV2, + ProviderAdapterRegistry.ProviderAdapterRegistryV2.of({ + get: (instanceId) => { + const adapter = adapters.find((candidate) => candidate.instanceId === instanceId); + return adapter === undefined + ? Effect.fail( + new ProviderAdapterRegistry.ProviderAdapterRegistryLookupError({ + instanceId, + }), + ) + : Effect.succeed(adapter); + }, + list: () => Effect.succeed(adapters.map((adapter) => adapter.instanceId)), + getMetadata: () => + Effect.succeed({ + driver: CLAUDE_DRIVER, + continuationKey: "claude:shared-native-account-history", + enabled: true, + capabilities: ClaudeProviderCapabilitiesV2, + }), + }), + ) + : ProviderAdapterRegistry.layerFromAdapters(adapters); + const handoffThreadId = ThreadId.make("thread:handoff-releases-session"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const send = (ordinal: number, modelSelection: ModelSelection, attempt = "") => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:handoff-releases-session:${ordinal}${attempt}`), + threadId: handoffThreadId, + messageId: MessageId.make(`message:handoff-releases-session:${ordinal}${attempt}`), + text: `Prompt ${ordinal}`, + attachments: [], + modelSelection, + dispatchMode: { type: queued ? "queue_after_active" : "start_immediately" }, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:handoff-releases-session:create"), + threadId: handoffThreadId, + projectId: ProjectId.make("project:handoff-releases-session"), + title: "Handoff releases session", + modelSelection: CLAUDE_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* send(1, CLAUDE_MODEL_SELECTION); + + if (queued) { + yield* Deferred.await(firstStarted); + yield* send(2, secondSelection); + const queuedRunId = (yield* orchestrator.getThreadProjection(handoffThreadId)).runs[1] + ?.id; + assert.isDefined(queuedRunId); + yield* Deferred.succeed(releaseFirst, undefined); + yield* orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + event.runId === queuedRunId && + (event.payload.queueHeld === true || event.payload.status === "completed"), + ), + Stream.runHead, + ); + yield* worker.drain(); + const afterQueue = yield* orchestrator.getThreadProjection(handoffThreadId); + const firstSession = afterQueue.providerSessions.find( + (session) => session.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + ); + // Detaching would end the background agents, so the busy session must stay up. + assert.deepEqual(yield* Ref.get(closedSessions), []); + assert.equal(firstSession?.status, "ready"); + if (!sharedHistory) { + assert.deepEqual( + afterQueue.runs.map((run) => [run.providerInstanceId, run.status]), + [ + [CLAUDE_MODEL_SELECTION.instanceId, "completed"], + [secondSelection.instanceId, "completed"], + ], + ); + return; + } + // Resuming the native thread beside the busy session would fork its history. + assert.deepEqual( + afterQueue.runs.map((run) => [run.status, run.queueHeld === true]), + [ + ["completed", false], + ["queued", true], + ], + ); + assert.lengthOf(yield* Ref.get(capturedTurns), 1); + yield* Ref.set(backgroundWork, false); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make("command:handoff-releases-session:resume"), + threadId: handoffThreadId, + }); + yield* worker.drain(); + const resumed = yield* waitForIdle(handoffThreadId); + assert.deepEqual( + resumed.runs.map((run) => [run.providerInstanceId, run.status]), + [ + [CLAUDE_MODEL_SELECTION.instanceId, "completed"], + [secondSelection.instanceId, "completed"], + ], + ); + assert.deepEqual(yield* Ref.get(closedSessions), [firstSession!.id]); + return; + } + + yield* worker.drain(); + const afterFirst = yield* waitForIdle(handoffThreadId); + const firstSession = afterFirst.providerSessions.find( + (session) => session.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + ); + assert.equal(firstSession?.status, "ready"); + + if (background) { + // Native subagents live only in the adapter's roster, so the projection's + // pendingBackgroundTasks stays empty while the probe still reports work. + assert.deepEqual(afterFirst.providerThreads[0]?.pendingBackgroundTasks ?? [], []); + const refused = yield* send(2, secondSelection, ":refused").pipe(Effect.flip); + assert.include(String(refused.cause), "still running background agents or commands"); + const unchanged = yield* orchestrator.getThreadProjection(handoffThreadId); + assert.lengthOf(unchanged.runs, 1); + assert.deepEqual(yield* Ref.get(closedSessions), []); + assert.equal(unchanged.thread.providerInstanceId, CLAUDE_MODEL_SELECTION.instanceId); + assert.deepEqual( + unchanged.providerSessions.map((session) => [session.id, session.status]), + [[firstSession!.id, "ready"]], + ); + yield* Ref.set(backgroundWork, false); + } + + yield* send(2, secondSelection); + yield* worker.drain(); + const afterSecond = yield* waitForIdle(handoffThreadId); + yield* worker.drain(); + const projection = yield* orchestrator.getThreadProjection(handoffThreadId); + + assert.deepEqual( + projection.runs.map((run) => [run.providerInstanceId, run.status]), + [ + [CLAUDE_MODEL_SELECTION.instanceId, "completed"], + [secondSelection.instanceId, "completed"], + ], + ); + assert.lengthOf(afterSecond.contextHandoffs, 1); + assert.deepEqual( + projection.providerSessions.map((session) => session.providerInstanceId), + [secondSelection.instanceId], + ); + // The detached event alone hides the session; the runtime must also close. + assert.deepEqual(yield* Ref.get(closedSessions), [firstSession!.id]); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { + name: "handoff-releases-session", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + layerRegistry, + ), + ), + ); + }), + ), + ); + // regression: #21314 a queued PR update must follow the provider that took over the thread it.live.each([ ["automatic", "Claude"], @@ -3006,7 +3253,7 @@ describe("orchestration v2 provider switching", () => { const layerImporterProvided = LegacyV1ThreadImporter.layer.pipe( Layer.provide(Layer.mergeAll(layerStoresProvided, layerEventSinkProvided)), ); - const layerMaintenanceProvided = ProjectionMaintenance.layer.pipe( + const layerMaintenanceProvided = ProjectionReplay.layer.pipe( Layer.provide(layerStoresProvided), ); const layerOrchestratorProvided = ProviderReplayHarness.layerWithRegistry( @@ -3035,7 +3282,7 @@ describe("orchestration v2 provider switching", () => { const projection = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const orchestrator = yield* Orchestrator.OrchestratorV2; const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; diff --git a/apps/server/src/orchestration-v2/testkit/ReplayRuntimeInstructions.ts b/apps/server/src/orchestration-v2/testkit/ReplayRuntimeInstructions.ts index 661856720fc27..0c3d59e93398b 100644 --- a/apps/server/src/orchestration-v2/testkit/ReplayRuntimeInstructions.ts +++ b/apps/server/src/orchestration-v2/testkit/ReplayRuntimeInstructions.ts @@ -11,11 +11,13 @@ export function materializeReplayTranscriptRuntimeInstructions( ? "Cursor" : runtime.driver === "grok" ? "Grok" - : runtime.driver === "acpRegistry" - ? "acpRegistry" - : runtime.driver === "fx" - ? "fx" - : undefined; + : runtime.driver === "kiro" + ? "Kiro" + : runtime.driver === "acpRegistry" + ? "acpRegistry" + : runtime.driver === "fx" + ? "fx" + : undefined; if (harness === undefined) return transcript; const instructions = buildRuntimeInstructions({ harness, model: runtime.model }); @@ -53,7 +55,10 @@ export function materializeReplayTranscriptRuntimeInstructions( "type" in lastPart && lastPart.type === "text" && "text" in lastPart && - lastPart.text === instructions + typeof lastPart.text === "string" && + // A recording that switched models mid-thread already carries the + // instructions for the model each prompt ran on. + (lastPart.text === instructions || lastPart.text.startsWith("")) ) { return entry; } diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index 40b3729f3336f..106c76d8b320f 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -22,6 +22,7 @@ import { } from "@t3tools/contracts"; import type * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; import type { OrchestratorV2ScenarioResult, @@ -174,6 +175,12 @@ export type OrchestratorFixtureInputStep = readonly text: string; readonly attachments?: ReadonlyArray; } + | { + /** Forks the thread at a run into a second thread, then sends `text` there. */ + readonly type: "fork"; + readonly fromRunIndex: number; + readonly text: string; + } | { readonly type: "queue_message"; readonly text: string; @@ -227,6 +234,10 @@ export type OrchestratorFixtureInputStep = readonly type: "interrupt"; readonly targetRunIndex: number; readonly waitForTurnItemType?: OrchestrationV2TurnItem["type"]; + /** With waitForTurnItemType: wait until that item has this status. */ + readonly waitForTurnItemStatus?: OrchestrationV2TurnItem["status"]; + /** With waitForTurnItemType: wait for it in one of the run's subagent threads. */ + readonly inSubagentThread?: boolean; } | { readonly type: "stop_background_work"; @@ -257,6 +268,11 @@ export type OrchestratorFixtureInputStep = | { readonly type: "advance_clock"; readonly duration: Duration.Input; + } + | { + /** Picks another model of the same provider instance; later messages use it. */ + readonly type: "set_model"; + readonly model: string; }; export interface OrchestratorFixtureInput { @@ -475,11 +491,14 @@ export function materializeFixtureInput(input: { const steps: Array = []; let messageIndex = 0; let runIndex = 0; + let forkThreadId: ThreadId | undefined; const activeRunDispatchKeys = new Set(); const runIdFor = (runOrdinal: number) => idAllocator.derive.run({ threadId: ids.threadId, ordinal: runOrdinal }); + // A `set_model` step changes the selection every later message carries. + let modelSelection = input.modelSelection; const pushDispatch = ( command: OrchestrationV2Command, options: { @@ -508,7 +527,7 @@ export function materializeFixtureInput(input: { }), ids, scenario: input.scenario, - modelSelection: input.modelSelection, + modelSelection, ...(input.fixtureInput.interactionMode === undefined ? {} : { interactionMode: input.fixtureInput.interactionMode }), @@ -551,7 +570,7 @@ export function materializeFixtureInput(input: { commandName: `message-${messageIndex}`, }), ids, - modelSelection: input.modelSelection, + modelSelection, messageId: yield* idAllocator.allocate.message({ threadId: ids.threadId, ordinal: messageIndex, @@ -585,7 +604,7 @@ export function materializeFixtureInput(input: { commandName: `queue-message-${messageIndex}`, }), ids, - modelSelection: input.modelSelection, + modelSelection, messageId: yield* idAllocator.allocate.message({ threadId: ids.threadId, ordinal: messageIndex, @@ -723,7 +742,7 @@ export function materializeFixtureInput(input: { commandName: `steer-${messageIndex}`, }), ids, - modelSelection: input.modelSelection, + modelSelection, messageId: yield* idAllocator.allocate.message({ threadId: ids.threadId, ordinal: messageIndex, @@ -763,7 +782,7 @@ export function materializeFixtureInput(input: { commandName: `restart-${messageIndex}`, }), ids, - modelSelection: input.modelSelection, + modelSelection, messageId: yield* idAllocator.allocate.message({ threadId: ids.threadId, ordinal: messageIndex, @@ -801,6 +820,10 @@ export function materializeFixtureInput(input: { threadId: ids.threadId, runId: runIdFor(step.targetRunIndex), itemType: step.waitForTurnItemType, + ...(step.waitForTurnItemStatus === undefined + ? {} + : { itemStatus: step.waitForTurnItemStatus }), + ...(step.inSubagentThread === true ? { inSubagentThreads: true } : {}), }); } pushDispatch( @@ -850,6 +873,56 @@ export function materializeFixtureInput(input: { case "advance_clock": steps.push({ type: "advance_clock", duration: step.duration }); break; + case "set_model": + modelSelection = { ...input.modelSelection, model: step.model }; + pushDispatch({ + type: "thread.model-selection.set", + commandId: yield* idAllocator.allocate.command({ + fixtureName: input.scenario, + commandName: `set-model-${step.model}`, + }), + threadId: ids.threadId, + modelSelection, + }); + break; + case "fork": + { + const targetThreadId = yield* idAllocator.allocate.thread({ + fixtureName: `${input.scenario}-fork`, + projectId: ids.projectId, + }); + forkThreadId = targetThreadId; + pushDispatch({ + type: "thread.fork", + createdBy: "user", + creationSource: "web", + commandId: yield* idAllocator.allocate.command({ + fixtureName: input.scenario, + commandName: "thread-fork", + }), + sourceThreadId: ids.threadId, + targetThreadId, + sourcePoint: { type: "run", runId: runIdFor(step.fromRunIndex) }, + title: "Forked thread", + }); + pushDispatch( + dispatchMessageCommand({ + commandId: yield* idAllocator.allocate.command({ + fixtureName: input.scenario, + commandName: "fork-message", + }), + ids: { ...ids, threadId: targetThreadId }, + modelSelection, + messageId: yield* idAllocator.allocate.message({ + threadId: targetThreadId, + ordinal: 1, + }), + text: step.text, + }), + ); + steps.push({ type: "await_thread_idle", threadId: targetThreadId }); + } + break; case "rollback": { const scopeId = yield* idAllocator.allocate.checkpointScope({ @@ -883,11 +956,17 @@ export function materializeFixtureInput(input: { return { commands, steps, - projectionThreadIds: [ids.threadId], + projectionThreadIds: + forkThreadId === undefined ? [ids.threadId] : [ids.threadId, forkThreadId], }; }); } +/** Reads `key` from a recorded frame or payload of unknown shape. */ +export function field(value: unknown, key: string): unknown { + return Predicate.hasProperty(value, key) ? value[key] : undefined; +} + export function projectionFor( result: OrchestratorV2ScenarioResult, scenario: string, diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index f4e54000222fc..111cf0541358a 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -2,6 +2,7 @@ import { MessageId, NodeId, RunId, + RuntimeRequestId, ThreadId, TurnItemId, type OrchestrationV2ProjectedTurnItem, @@ -66,6 +67,26 @@ function makeRow( }; } +function makeTurnStart( + index: number, + createdBy: "user" | "agent", +): OrchestrationV2ProjectedTurnItem { + const row = makeRow(index); + return { + ...row, + item: { + ...row.item, + type: "user_message", + createdBy, + creationSource: "provider", + inputIntent: "turn_start", + messageId: MessageId.make(`prompt-${index}`), + text: `Prompt ${index}`, + attachments: [], + }, + } as OrchestrationV2ProjectedTurnItem; +} + function interruptItem( id: string, type: "run_interrupt_request" | "run_interrupt_result", @@ -450,6 +471,65 @@ describe("threadHistoryPaging", () => { } }); + it.each([ + { + name: "item ceiling", + items: Array.from({ length: 5 }, (_, index) => makeRow(index)), + policy: { maxItems: 3, maxEncodedBytes: 1_000 }, + admitted: 3, + measured: 3, + }, + { + name: "user turn ceiling", + items: [makeRow(0), makeTurnStart(1, "user"), makeRow(2)], + policy: { maxUserTurns: 1, maxItems: 100, maxEncodedBytes: 1_000 }, + admitted: 2, + measured: 2, + }, + { + name: "raw turn ceiling", + items: [ + makeTurnStart(0, "user"), + ...Array.from({ length: 151 }, (_, index) => makeTurnStart(index + 1, "agent")), + ], + policy: { maxUserTurns: 10, maxItems: 1_000, maxEncodedBytes: 1_000_000 }, + admitted: 150, + measured: 150, + }, + { + name: "byte budget, which still admits an exact fit", + items: Array.from({ length: 5 }, (_, index) => makeRow(index)), + policy: { maxItems: 100, maxEncodedBytes: 20 }, + admitted: 2, + measured: 3, + }, + { + name: "byte budget, which still admits an oversized first row", + items: Array.from({ length: 5 }, (_, index) => makeRow(index)), + policy: { maxItems: 100, maxEncodedBytes: 5 }, + admitted: 1, + measured: 2, + }, + ])("measures only the rows needed to stop at the $name", (testCase) => { + const measured: OrchestrationV2ProjectedTurnItem[] = []; + const page = selectRecentTimelineWindow({ + items: testCase.items, + snapshotSequence: 1, + policy: testCase.policy, + rowEncodedBytes: (row) => { + measured.push(row); + return 10; + }, + }); + + expect(page.items.map((row) => row.sourceItemId)).toEqual( + testCase.items.slice(-testCase.admitted).map((row) => row.sourceItemId), + ); + expect(measured).toHaveLength(testCase.measured); + expect(page.hasMoreHistory).toBe(true); + expect(decodeThreadHistoryCursor(page.nextCursor!).si).toBe(page.items[0]?.sourceItemId); + }); + it("recovers identity-miss cursors when position is past a shrunken timeline", () => { const items = Array.from({ length: 5 }, (_, index) => makeRow(index)); const cursor = encodeThreadHistoryCursor({ @@ -738,6 +818,69 @@ describe("threadHistoryPaging", () => { ).toBe(2_097_152); }); + it.each([40_000, 80_000])( + "reserves pending question cards before visible history: %s bytes", + (questionBytes) => { + const rows = Array.from({ length: 20 }, (_, index) => + makeRow(index + 1, { outputBytes: 1_500 }), + ); + const requestId = RuntimeRequestId.make("pending-question"); + const card: OrchestrationV2TurnItem = { + id: TurnItemId.make("pending-question-card"), + type: "user_input_request", + threadId: THREAD, + runId: null, + nodeId: NODE, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed", + title: null, + requestId, + responseMode: "message", + questions: [ + { id: "scope", header: "Scope", question: "q".repeat(questionBytes), options: [] }, + ], + startedAt: NOW, + completedAt: NOW, + updatedAt: NOW, + }; + const projection = { + ...makeProjection(rows), + turnItems: [card, ...rows.map((row) => row.item)], + runtimeRequests: [ + { + id: requestId, + nodeId: NODE, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input" as const, + status: "pending" as const, + responseCapability: { type: "message" as const }, + createdAt: NOW, + resolvedAt: null, + }, + ], + }; + const policy = { ...THREAD_HISTORY_PAGE_POLICY, maxEncodedBytes: 64_000 }; + + const bounded = buildBoundedThreadProjection({ projection, snapshotSequence: 9, policy }); + + expect(bounded.projection.turnItems).toContain(card); + expect(bounded.projection.visibleTurnItems.length).toBeGreaterThan(0); + expect(bounded.hasMoreHistory).toBe(true); + if (questionBytes < policy.maxEncodedBytes) { + expect(Buffer.byteLength(JSON.stringify(bounded.projection), "utf8")).toBeLessThanOrEqual( + policy.maxEncodedBytes, + ); + } else { + expect(bounded.projection.visibleTurnItems).toHaveLength(1); + } + }, + ); + it("preserves oversized actionable state", () => { const projection = makeProjection([makeRow(0)]); const actionable = { @@ -761,39 +904,46 @@ describe("threadHistoryPaging", () => { expect(bounded.projection.visibleTurnItems).toEqual(actionable.visibleTurnItems); }); - it("keeps agent-only turns visible when checkpoint file lists fill the byte budget", () => { - const items = Array.from({ length: 3 }, (_, turn) => { - const first = makeRow(turn * 5); - const prompt = { - ...first, - item: { - ...first.item, - type: "user_message" as const, - createdBy: "agent" as const, - creationSource: "provider" as const, - inputIntent: "turn_start" as const, - messageId: MessageId.make(`agent-prompt-${turn}`), - text: `Step ${turn}`, - attachments: [], - }, - } as OrchestrationV2ProjectedTurnItem; - return [prompt, ...Array.from({ length: 4 }, (_, index) => makeRow(turn * 5 + index + 1))]; - }).flat(); - const files = Array.from( - { length: 15_000 }, - (_, index) => - `work/ws-${index % 40}/.jj/repo/store/extra/${index.toString(16).padStart(40, "0")}`, - ); - const projection = { - ...makeProjection(items), - checkpoints: [{ id: "checkpoint-1", files }], - } as unknown as ReturnType; - - const bounded = buildBoundedThreadProjection({ projection, snapshotSequence: 1 }); - - expect(bounded.projection.visibleTurnItems).toHaveLength(items.length); - expect(bounded.hasMoreHistory).toBe(false); - }); + it.each([false, true])( + "keeps agent-only turns visible when checkpoint file lists fill the byte budget (stored interrupt request: %s)", + (withInterruptRequest) => { + const items = Array.from({ length: 3 }, (_, turn) => { + const first = makeRow(turn * 5); + const prompt = { + ...first, + item: { + ...first.item, + type: "user_message" as const, + createdBy: "agent" as const, + creationSource: "provider" as const, + inputIntent: "turn_start" as const, + messageId: MessageId.make(`agent-prompt-${turn}`), + text: `Step ${turn}`, + attachments: [], + }, + } as OrchestrationV2ProjectedTurnItem; + return [prompt, ...Array.from({ length: 4 }, (_, index) => makeRow(turn * 5 + index + 1))]; + }).flat(); + const files = Array.from( + { length: 15_000 }, + (_, index) => + `work/ws-${index % 40}/.jj/repo/store/extra/${index.toString(16).padStart(40, "0")}`, + ); + const base = makeProjection(items); + const projection = { + ...base, + turnItems: withInterruptRequest + ? [interruptItem("old-interrupt-req", "run_interrupt_request", 0), ...base.turnItems] + : base.turnItems, + checkpoints: [{ id: "checkpoint-1", files }], + } as unknown as ReturnType; + + const bounded = buildBoundedThreadProjection({ projection, snapshotSequence: 1 }); + + expect(bounded.projection.visibleTurnItems).toHaveLength(items.length); + expect(bounded.hasMoreHistory).toBe(false); + }, + ); it("keeps paged historical plan detail in the turn item and only status in its artifact", () => { const detail = "Implement the historical plan exactly.\n".repeat(1_000); diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index 03d056531a760..8bc794ce36c4d 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -5,6 +5,7 @@ import type { ThreadId, TurnItemId, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; /** * Every window stops at the byte budget. A window that holds a user turn also @@ -84,11 +85,12 @@ export type BoundedProjectionResult = { readonly latestLocalTurnOrdinal: number | null; }; -export class InvalidThreadHistoryCursorError extends Error { - readonly _tag = "InvalidThreadHistoryCursorError"; - constructor(message = "Invalid thread history cursor.") { - super(message); - this.name = "InvalidThreadHistoryCursorError"; +export class InvalidThreadHistoryCursorError extends Schema.TaggedError()( + "InvalidThreadHistoryCursorError", + {}, +) { + override get message(): string { + return "Invalid thread history cursor."; } } @@ -331,16 +333,16 @@ function selectOlderTimelinePage(input: { turnLimit === undefined ? policy.maxItems : (policy.maxTurnItems ?? policy.maxItems); for (let index = end - 1; index >= 0; index -= 1) { const row = input.items[index]!; - const rowBytes = rowCost(row); if ( selected.length > 0 && (selected.length >= maxItems || - encodedBytes + rowBytes > policy.maxEncodedBytes || (turnLimit !== undefined && (userTurns >= turnLimit || rawTurns >= THREAD_HISTORY_MAX_RAW_TURNS))) ) { break; } + const rowBytes = rowCost(row); + if (selected.length > 0 && encodedBytes + rowBytes > policy.maxEncodedBytes) break; selected.push(row); encodedBytes += rowBytes; if (isThreadHistoryUserTurn(row.item)) userTurns += 1; @@ -673,10 +675,9 @@ export function buildBoundedThreadProjection(input: { turnItems: [], visibleTurnItems: [], }); - // Control state (checkpoint file lists, the latest task list) cannot be paged, so it may claim at most - // half the budget; past that the window would shrink to the one row every page admits. + // Control state cannot be paged, so the window keeps half the budget; dependency cards take their reserve from that half. const windowBudget = Math.max( - policy.maxEncodedBytes / 2, + Math.max(policy.maxEncodedBytes / 2 - dependencyReserve, 0), policy.maxEncodedBytes - dependencyReserve - controlBytes - 1_024, ); const windowPolicy: ThreadHistoryPagePolicy = { ...policy, maxEncodedBytes: windowBudget }; diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts index 587a19f649986..0420d45f22794 100644 --- a/apps/server/src/peer/PeerForwarding.ts +++ b/apps/server/src/peer/PeerForwarding.ts @@ -10,7 +10,7 @@ import { OrchestratorMcpThreadWaitResult, } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -94,7 +94,7 @@ export const remoteTarget = (scope: Scope, environmentId: EnvironmentId | undefi const make = Effect.gen(function* () { const peers = yield* PeerMcpClient.PeerMcpClient; const links = yield* PeerLinks.PeerLinks; - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const threads = yield* ThreadManagement.ThreadManagementService; /** @@ -117,7 +117,7 @@ const make = Effect.gen(function* () { * key never collide there, and one agent retrying gets its first result. */ const scopedRequestId = (namespace: string, clientRequestId: string) => - crypto.digest("SHA-256", new TextEncoder().encode(`${namespace}\n${clientRequestId}`)).pipe( + crypto.digest(Digest.sha256, new TextEncoder().encode(`${namespace}\n${clientRequestId}`)).pipe( Effect.map((digest) => `fwd:${Hex.encode(digest)}`), Effect.orDie, ); diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index a8e225b406914..d8dca0ac3e652 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -68,16 +68,18 @@ const make = Effect.gen(function* () { ), ); + const get: PeerLinks["Service"]["get"] = (environmentId) => + list.pipe( + Effect.map((links) => + Option.fromNullishOr(links.find((link) => link.environmentId === environmentId)), + ), + Effect.map(Option.map(({ status: _status, ...link }): PeerLink => link)), + ); + return PeerLinks.of({ enabled, list, - get: (environmentId) => - list.pipe( - Effect.map((links) => - Option.fromNullishOr(links.find((link) => link.environmentId === environmentId)), - ), - Effect.map(Option.map(({ status: _status, ...link }): PeerLink => link)), - ), + get, }); }); diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index e789bfb228301..b0a1b4ff2c1b4 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -15,7 +15,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -122,7 +122,7 @@ const make = Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const projects = yield* ProjectService.ProjectService; const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const hereId = yield* ServerEnvironment.ServerEnvironmentIdentity.pipe( Effect.flatMap((environment) => environment.getEnvironmentId), ); @@ -257,7 +257,7 @@ const make = Effect.gen(function* () { // One key for the launch there and the record here, so a retry finds both. // Both are scoped to the provider session, as every key a caller passes is: // the forwarder scopes the launch's, and the record's names it. - const key = input.clientRequestId ?? (yield* crypto.randomUUIDv4().pipe(Effect.orDie)); + const key = input.clientRequestId ?? (yield* random.uuidV4()); const task = taskPrompt(input); const launched = yield* forwarding.call( scope, @@ -370,7 +370,7 @@ const make = Effect.gen(function* () { stoppedThere: "ran-out" | "stopped" | undefined, stopCount?: number, ) { - const attempt = yield* crypto.randomUUIDv4().pipe(Effect.orDie); + const attempt = yield* random.uuidV4(); yield* threads .dispatch({ type: "delegated_task.remote.complete", @@ -700,7 +700,7 @@ const make = Effect.gen(function* () { const there = followScope(scope.thread, remote); // A later cancel stops what started there since; a retry of this one, nothing new. const key = `cancel:${encodeURIComponent(scope.requestNamespace)}:${ - input.clientRequestId ?? (yield* crypto.randomUUIDv4().pipe(Effect.orDie)) + input.clientRequestId ?? (yield* random.uuidV4()) }`; if (task.result !== null) { // Best effort: the result stands whether or not the stop gets there. diff --git a/apps/server/src/persistence/CoderMigrationHistory.ts b/apps/server/src/persistence/CoderMigrationHistory.ts index 6baaffe947711..54b521dfbd87f 100644 --- a/apps/server/src/persistence/CoderMigrationHistory.ts +++ b/apps/server/src/persistence/CoderMigrationHistory.ts @@ -8,9 +8,12 @@ * finishes the old registry, whose final schema matches upstream ID 54, and then * has its history rewritten to upstream's IDs. */ +import * as Arr from "effect/Array"; import * as Effect from "effect/Effect"; +import type * as Schema from "effect/Schema"; import * as Migrator from "effect/sql/Migrator"; import * as SqlClient from "effect/sql/SqlClient"; +import type * as SqlError from "effect/sql/SqlError"; import RepairPendingUserInputCounts from "./Migrations/CoderLegacy/050_RepairPendingUserInputCounts.ts"; import ProjectionMessageAttachments from "./Migrations/CoderLegacy/055_ProjectionMessageAttachments.ts"; @@ -18,7 +21,7 @@ import ProjectionMessageAttachments from "./Migrations/CoderLegacy/055_Projectio type MigrationEntry = readonly [ id: number, name: string, - migration: Effect.Effect, + migration: Effect.Effect, ]; /** Upstream IDs the old registry omitted. */ @@ -117,7 +120,10 @@ export const reconcileCoderMigrationHistory = Effect.fn("reconcileCoderMigration yield* sql.withTransaction( Effect.gen(function* () { yield* sql`DELETE FROM effect_sql_migrations`; - yield* sql`INSERT INTO effect_sql_migrations ${sql.insert(rows)}`; + // Effect 4.1's array insert takes a non-empty array. + if (Arr.isArrayNonEmpty(rows)) { + yield* sql`INSERT INTO effect_sql_migrations ${sql.insert(rows)}`; + } }), ); yield* Effect.log("Adopted upstream migration IDs"); diff --git a/apps/server/src/persistence/DatabaseStorage.test.ts b/apps/server/src/persistence/DatabaseStorage.test.ts index 878f596600888..73a0d4cac7cd8 100644 --- a/apps/server/src/persistence/DatabaseStorage.test.ts +++ b/apps/server/src/persistence/DatabaseStorage.test.ts @@ -9,9 +9,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../config.ts"; -import * as EventStore from "../orchestration-v2/EventStore.ts"; import * as ProjectionMaintenance from "../orchestration-v2/ProjectionMaintenance.ts"; -import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as DatabaseStorage from "./DatabaseStorage.ts"; import * as Sqlite from "./Sqlite.ts"; @@ -28,7 +26,6 @@ const storageLayer = ( ) => DatabaseStorage.layer.pipe( Layer.provide(ProjectionMaintenance.layer), - Layer.provide(Layer.merge(EventStore.layer, ProjectionStore.layer)), Layer.provideMerge(Sqlite.layerMemory), Layer.provideMerge(config), Layer.provide(NodeServices.layer), @@ -54,14 +51,12 @@ it.effect("a new database returns freed pages online without losing data", () => ); const compactionLayer = ( - compactEventStore: ProjectionMaintenance.ProjectionMaintenanceV2["Service"]["compactEventStore"], + pruneEventStore: ProjectionMaintenance.ProjectionMaintenanceV2["Service"]["pruneEventStore"], ) => DatabaseStorage.layer.pipe( Layer.provide( Layer.succeed(ProjectionMaintenance.ProjectionMaintenanceV2, { - compactEventStore, - verify: Effect.die("unexpected verification"), - rebuild: Effect.die("unexpected rebuild"), + pruneEventStore, }), ), Layer.provide(Sqlite.layerMemory), diff --git a/apps/server/src/persistence/DatabaseStorage.ts b/apps/server/src/persistence/DatabaseStorage.ts index d8123e2e9400c..fa5b14f7cbf71 100644 --- a/apps/server/src/persistence/DatabaseStorage.ts +++ b/apps/server/src/persistence/DatabaseStorage.ts @@ -26,7 +26,6 @@ export class DatabaseStorage extends Context.Service< } >()("t3/persistence/DatabaseStorage") {} -// Deleted events have newer retained replacements; old cursors replay to the same state. const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; @@ -74,7 +73,7 @@ const make = Effect.gen(function* () { yield* Effect.sleep((Number(elapsed) / 1_000_000) * 9); sliceStarted = yield* Clock.monotonicTimeNanos; }); - const result = yield* maintenance.compactEventStore(betweenPages); + const result = yield* maintenance.pruneEventStore(betweenPages); const completedAt = yield* Clock.currentTimeMillis; yield* fs.writeFileString(compactedAtPath, String(completedAt)).pipe(Effect.ignore); if (result.deletedEventCount > 0 || result.deletedReceiptCount > 0) { diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 67f238b7484f5..5867647f55489 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -75,6 +75,44 @@ describe("runMigrations", () => { }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); + it.effect("drops the unused correlation index and keeps correlation values", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 54 }); + yield* sql` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, + command_id, causation_event_id, correlation_id, actor_kind, payload_json, metadata_json + ) + VALUES ( + 'event-1', 'thread', 'thread-1', 0, 'thread.created', '2026-01-01T00:00:00.000Z', + 'command-1', NULL, 'correlation-1', 'client', '{}', '{}' + ) + `; + + yield* runMigrations(); + + const indexes = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'index' AND tbl_name = 'orchestration_events' AND sql IS NOT NULL + `; + const indexNames = indexes.map((row) => row.name); + assert.notInclude(indexNames, "idx_orch_events_correlation_id"); + for (const kept of [ + "idx_orch_events_stream_version", + "idx_orch_events_stream_sequence", + "idx_orch_events_command_id", + "idx_orchestration_events_agent_stream_sequence", + ]) { + assert.include(indexNames, kept); + } + const events = yield* sql<{ readonly correlation_id: string | null }>` + SELECT correlation_id FROM orchestration_events WHERE event_id = 'event-1' + `; + assert.deepStrictEqual(events, [{ correlation_id: "correlation-1" }]); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), + ); + it.effect.each([30, 54])("still starts with a site-local migration at id %s", (id) => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b56fc51c16183..705cbcc3b6650 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -73,6 +73,9 @@ import Migration0074 from "./Migrations/074_ExcludeAttachmentPlaceholdersFromSea import Migration0075 from "./Migrations/075_SubagentDeliveryState.ts"; import Migration0076 from "./Migrations/076_ProjectionProjectsPinArchive.ts"; import Migration0077 from "./Migrations/077_RepairBooleanSettledOverride.ts"; +import Migration0078 from "./Migrations/078_DropOrchestrationEventCorrelationIndex.ts"; + +import Migration0079 from "./Migrations/079_DelegatedRecoveryIndexes.ts"; // Coder: T3 Coder has no auth sessions or pairing. Upstream's auth migrations keep // their IDs so later upstream migrations stay aligned, but they create nothing. @@ -160,6 +163,8 @@ export const migrationEntries = [ [75, "SubagentDeliveryState", Migration0075], [76, "ProjectionProjectsPinArchive", Migration0076], [77, "RepairBooleanSettledOverride", Migration0077], + [78, "DropOrchestrationEventCorrelationIndex", Migration0078], + [79, "DelegatedRecoveryIndexes", Migration0079], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts index 69eb8eff57027..b53309c187ed3 100644 --- a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts +++ b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts @@ -9,7 +9,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as EventStore from "../../orchestration-v2/EventStore.ts"; import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; -import * as ProjectionMaintenance from "../../orchestration-v2/ProjectionMaintenance.ts"; +import * as ProjectionReplay from "../../orchestration-v2/testkit/ProjectionReplay.ts"; import { listLinkedPullRequestThreads } from "../../pullRequest/linkedThreads.ts"; import { runMigrations } from "../Migrations.ts"; @@ -53,7 +53,7 @@ const thread = (id: string) => ({ }); const layer = it.layer( - ProjectionMaintenance.layer.pipe( + ProjectionReplay.layer.pipe( Layer.provideMerge( Layer.mergeAll(EventStore.layer, ProjectionStore.layer).pipe( Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), @@ -156,7 +156,7 @@ layer("064_CanonicalizeThreadPullRequestLinks", (it) => { `; assert.equal(v1?.payload_json, v1Payload); - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; assert.isTrue((yield* maintenance.rebuild).valid); for (const [host, repository, id] of [ ["github.com", "acme/web", "github"], diff --git a/apps/server/src/persistence/Migrations/066_RepairClientSettlementTimestamps.test.ts b/apps/server/src/persistence/Migrations/066_RepairClientSettlementTimestamps.test.ts index f30289cb92de9..d62654b37ece6 100644 --- a/apps/server/src/persistence/Migrations/066_RepairClientSettlementTimestamps.test.ts +++ b/apps/server/src/persistence/Migrations/066_RepairClientSettlementTimestamps.test.ts @@ -6,13 +6,13 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; import * as EventStore from "../../orchestration-v2/EventStore.ts"; -import * as ProjectionMaintenance from "../../orchestration-v2/ProjectionMaintenance.ts"; +import * as ProjectionReplay from "../../orchestration-v2/testkit/ProjectionReplay.ts"; import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; import { runMigrations } from "../Migrations.ts"; import Migration0066 from "./066_RepairClientSettlementTimestamps.ts"; const layer = it.layer( - ProjectionMaintenance.layer.pipe( + ProjectionReplay.layer.pipe( Layer.provideMerge( Layer.mergeAll(EventStore.layer, ProjectionStore.layer).pipe( Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), @@ -363,7 +363,7 @@ layer("066_RepairClientSettlementTimestamps", (it) => { yield* Migration0066; assert.deepEqual(yield* snapshot, repaired); - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; assert.isTrue((yield* maintenance.rebuild).valid); assert.deepEqual(yield* imported, expectedImported); }), diff --git a/apps/server/src/persistence/Migrations/078_DropOrchestrationEventCorrelationIndex.ts b/apps/server/src/persistence/Migrations/078_DropOrchestrationEventCorrelationIndex.ts new file mode 100644 index 0000000000000..bf00778eff5d9 --- /dev/null +++ b/apps/server/src/persistence/Migrations/078_DropOrchestrationEventCorrelationIndex.ts @@ -0,0 +1,10 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // No query filters events by correlation_id, so this index only adds write + // and storage cost. The column and its values stay. + yield* sql`DROP INDEX IF EXISTS idx_orch_events_correlation_id`; +}); diff --git a/apps/server/src/persistence/Migrations/079_DelegatedRecoveryIndexes.ts b/apps/server/src/persistence/Migrations/079_DelegatedRecoveryIndexes.ts new file mode 100644 index 0000000000000..3ba72feaf1788 --- /dev/null +++ b/apps/server/src/persistence/Migrations/079_DelegatedRecoveryIndexes.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Include the parent ID so transfer checks do not reread each child payload. + yield* sql` + CREATE INDEX orchestration_v2_projection_threads_subagent_result_idx + ON orchestration_v2_projection_threads( + thread_id, json_extract(payload_json, '$.lineage.parentThreadId') + ) + WHERE CASE WHEN json_valid(payload_json) THEN + json_extract(payload_json, '$.lineage.relationshipToParent') = 'subagent' + AND json_extract(payload_json, '$.lineage.parentThreadId') IS NOT NULL + AND json_extract(payload_json, '$.forkedFrom.type') = 'node' + ELSE 0 END + `; + yield* sql` + CREATE INDEX orchestration_v2_projection_runs_delegated_delivery_idx + ON orchestration_v2_projection_runs(thread_id) + WHERE CASE WHEN json_valid(payload_json) + THEN json_type(payload_json, '$.delegatedCompletion.delivery') = 'object' + ELSE 0 END + `; +}); diff --git a/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts b/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts index 07f4fe5c9fbfb..c317ab845738b 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts @@ -1,5 +1,6 @@ import { CommandId, EventId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; +import * as Arr from "effect/Array"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -53,7 +54,7 @@ function eventRow( } const seedEvents = Effect.fn("test.seedSequenceEvents")(function* ( - rows: ReadonlyArray>, + rows: Arr.NonEmptyReadonlyArray>, ) { const sql = yield* SqlClient.SqlClient; return yield* sql<{ @@ -117,7 +118,7 @@ it.effect( Effect.gen(function* () { const store = yield* OrchestrationEventStore.OrchestrationEventStore; const rows = yield* seedEvents( - Array.from({ length: 1_560 }, (_, index) => { + Arr.makeBy(1_560, (index) => { const kind = index % 3 === 1 ? "project" : "thread"; return eventRow( index, diff --git a/apps/server/src/persistence/OrchestrationEventStore.ts b/apps/server/src/persistence/OrchestrationEventStore.ts index 51c3861a9fca0..e55d82713871b 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.ts @@ -33,6 +33,10 @@ import { type OrchestrationEventStoreError, } from "./Errors.ts"; +/** Projection metadata rows: events at or below the pruned-through sequence may be gone. */ +export const EVENT_LOG_PRUNED_THROUGH = "event-log-pruned-through"; +export const EVENT_LOG_PRUNE_MARK = "event-log-prune-mark"; + export type UnsequencedProjectEvent = ApplicationProjectEvent extends infer Event ? Event extends ApplicationProjectEvent ? Omit @@ -93,6 +97,9 @@ export class OrchestrationEventStore extends Context.Service< readonly latestApplicationSequence: Effect.Effect; + /** Replay from a cursor below this misses pruned events; resync from a snapshot instead. */ + readonly prunedThroughSequence: Effect.Effect; + /** Unknown event types fail unless skipUnknownEventTypes logs and drops them; only client replay may opt in. */ readonly readApplicationEvents: (input: { readonly afterSequence: number; @@ -450,7 +457,7 @@ const makeEventStore = Effect.gen(function* () { const promptKey = subagent === undefined ? undefined : `${event.type}:${subagent.id}`; const hint = promptKey === undefined ? undefined : promptHints.get(promptKey); const payloadJson = yield* encodeJson(encoded.payload); - // Deduplicate repeated progress prompts via anchor rows that ProjectionMaintenance never deletes. + // Deduplicate repeated progress prompts via anchor rows; pruning keeps anchors that retained rows reference. const storedPayload = hint !== undefined && subagent !== undefined ? sql`CASE WHEN EXISTS ( @@ -645,7 +652,8 @@ const makeEventStore = Effect.gen(function* () { FROM orchestration_events ${ threadId === undefined - ? sql`` + ? // The planner can otherwise pick the covering stream index and scan every agent event. + sql`INDEXED BY idx_orchestration_events_application_sequence` : sql`INDEXED BY idx_orchestration_events_agent_stream_sequence` } WHERE application_event_version = 2 @@ -671,6 +679,15 @@ const makeEventStore = Effect.gen(function* () { ), ); + const prunedThroughSequence = sql<{ readonly last_sequence: number }>` + SELECT last_sequence FROM orchestration_v2_projection_metadata + WHERE projection_name = ${EVENT_LOG_PRUNED_THROUGH} + `.pipe( + NodeSqliteClient.readOnly, + Effect.map((rows) => rows[0]?.last_sequence ?? 0), + Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.prunedThroughSequence:query")), + ); + // Advance over raw rows so even a full page of unknown types cannot end replay early. const catchUpApplicationEvents = (input: { readonly afterSequence: number; @@ -747,6 +764,7 @@ const makeEventStore = Effect.gen(function* () { getReplayStats, latestAgentSequence, latestApplicationSequence, + prunedThroughSequence, readApplicationEvents: catchUpApplicationEvents, publishCommitted: (events) => PubSub.publishAll(committedEvents, events).pipe(Effect.asVoid), streamApplicationEvents, diff --git a/apps/server/src/persistence/initializeV2Database.test.ts b/apps/server/src/persistence/initializeV2Database.test.ts index ec8ded1cab5ac..f8c68f49ceb9a 100644 --- a/apps/server/src/persistence/initializeV2Database.test.ts +++ b/apps/server/src/persistence/initializeV2Database.test.ts @@ -11,6 +11,7 @@ import { ThreadId } from "@t3tools/contracts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Predicate from "effect/Predicate"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../config.ts"; @@ -124,7 +125,8 @@ it.effect("includes committed WAL data and does not publish a failed snapshot", const destinationPath = NodePath.join(directory, "statev2.sqlite"); return Effect.gen(function* () { NodeFS.writeFileSync(sourcePath, "invalid SQLite"); - assert.isTrue((yield* Effect.result(initializeV2Database(destinationPath)))._tag === "Failure"); + const failure = yield* Effect.flip(initializeV2Database(destinationPath)); + assert.isFalse(Predicate.isTagged(failure.cause, "V2DatabaseImportError")); assert.isFalse(NodeFS.existsSync(destinationPath)); NodeFS.unlinkSync(sourcePath); const source = new NodeSqlite.DatabaseSync(sourcePath); diff --git a/apps/server/src/persistence/initializeV2Database.ts b/apps/server/src/persistence/initializeV2Database.ts index 4f6d8d969c3ec..492567c7980da 100644 --- a/apps/server/src/persistence/initializeV2Database.ts +++ b/apps/server/src/persistence/initializeV2Database.ts @@ -14,6 +14,8 @@ export class V2DatabaseImportError extends Schema.TaggedError { if (!Number.isInteger(pid) || pid <= 0) return false; try { @@ -58,13 +60,16 @@ export const initializeV2Database = Effect.fn("initializeV2Database")(function* prefix: `.v2-import-${process.pid}-`, }); const snapshotPath = path.join(temporaryDirectory, "snapshot.sqlite"); - yield* Effect.tryPromise(async () => { - const database = new NodeSqlite.DatabaseSync(sourcePath, { readOnly: true }); - try { - await NodeSqlite.backup(database, snapshotPath); - } finally { - database.close(); - } + yield* Effect.tryPromise({ + try: async () => { + const database = new NodeSqlite.DatabaseSync(sourcePath, { readOnly: true }); + try { + await NodeSqlite.backup(database, snapshotPath); + } finally { + database.close(); + } + }, + catch: (cause) => new V2DatabaseImportError({ sourcePath, destinationPath, cause }), }); // Publish only a complete snapshot, without replacing an existing V2 database. yield* fs.link(snapshotPath, destinationPath).pipe( @@ -75,6 +80,10 @@ export const initializeV2Database = Effect.fn("initializeV2Database")(function* ); }).pipe( Effect.scoped, - Effect.mapError((cause) => new V2DatabaseImportError({ sourcePath, destinationPath, cause })), + Effect.mapError((cause) => + isV2DatabaseImportError(cause) + ? cause + : new V2DatabaseImportError({ sourcePath, destinationPath, cause }), + ), ); }); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 4083978758ce0..5f51c237fd763 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -57,6 +57,8 @@ describe("V2 preview upgrade", () => { [75, "SubagentDeliveryState"], [76, "ProjectionProjectsPinArchive"], [77, "RepairBooleanSettledOverride"], + [78, "DropOrchestrationEventCorrelationIndex"], + [79, "DelegatedRecoveryIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -157,6 +159,8 @@ describe("V2 preview upgrade", () => { [75, "SubagentDeliveryState"], [76, "ProjectionProjectsPinArchive"], [77, "RepairBooleanSettledOverride"], + [78, "DropOrchestrationEventCorrelationIndex"], + [79, "DelegatedRecoveryIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/persistence/testkit/SqliteRouting.ts b/apps/server/src/persistence/testkit/SqliteRouting.ts new file mode 100644 index 0000000000000..2dcd65ec2b2f9 --- /dev/null +++ b/apps/server/src/persistence/testkit/SqliteRouting.ts @@ -0,0 +1,52 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Statement from "effect/sql/Statement"; +import * as Tracer from "effect/Tracer"; + +import * as Sqlite from "../Sqlite.ts"; + +export const layerReaderDatabase = Layer.unwrap( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-routing-" }); + return Sqlite.layerFromPath(path.join(directory, "state.sqlite")); + }), +).pipe(Layer.provide(NodeServices.layer)); + +export const traceSqlRouting = () => { + const queries: Array<{ query: string; connection: unknown }> = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + const query = span.attributes.get("db.query.text"); + if (typeof query === "string") { + let owner: Tracer.AnySpan | undefined = span; + let connection: unknown; + while (owner?._tag === "Span") { + connection = owner.attributes.get("db.connection"); + if (connection !== undefined) break; + owner = Option.getOrUndefined(owner.parent); + } + queries.push({ query, connection }); + } + }; + return span; + }, + }); + return { + queries, + trace: (effect: Effect.Effect) => + effect.pipe( + Effect.withTracer(tracer), + Effect.provideService(Statement.SpanPropagationEnabled, true), + ), + }; +}; diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index e026b204c03d8..b2b6e1ac9fbe0 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -24,6 +24,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import { FetchHttpClient, HttpClient } from "effect/http"; +import * as WindowsTcpListeners from "../process/windowsTcpListeners.ts"; import * as ProcessRunner from "../processRunner.ts"; export class PortDiscovery extends Context.Service< @@ -76,8 +77,6 @@ const LSOF_TIMEOUT_MS = 5_000; const PROC_FD_WALK_LIMIT = 50_000; const SOCKET_OWNER_ATTEMPTS = 3; const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; -/** `netstat -ano` lists every connection, not just listeners; ~80 bytes per row, sized for ~200k rows. */ -const WINDOWS_NETSTAT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const WEB_PROBE_TIMEOUT = Duration.seconds(1); const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15)); /** Bounds how long a cached Windows image name can outlive its process if Windows reuses the PID. */ @@ -272,53 +271,6 @@ const parsePortFromLsofName = (name: string): number | null => { return port; }; -const parseWindowsListenerLine = ( - line: string, -): { - readonly host: string; - readonly port: number; - readonly pid: number | null; - readonly processName: string | null; -} | null => { - const trimmed = line.trim(); - if (trimmed.length === 0) return null; - - if (trimmed.includes("|")) { - const [hostRaw, portRaw, pidRaw, processNameRaw] = trimmed.split("|", 4); - const host = hostRaw?.trim() ?? ""; - if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") return null; - const port = Number(portRaw); - const pid = Number(pidRaw); - if (!Number.isInteger(port) || port <= 0 || port >= 65536) return null; - return { - host, - port, - pid: Number.isInteger(pid) && pid > 0 ? pid : null, - processName: processNameRaw?.trim() || null, - }; - } - - // Foreign port `:0` identifies a listening TCP socket regardless of Windows locale. - const fields = trimmed.split(/\s+/); - if (fields.length < 4 || fields[0]?.toUpperCase() !== "TCP") return null; - const localEndpoint = fields[1] ?? ""; - const foreignEndpoint = fields[2] ?? ""; - if (!foreignEndpoint.endsWith(":0")) return null; - const lastColon = localEndpoint.lastIndexOf(":"); - if (lastColon <= 0) return null; - const host = localEndpoint.slice(0, lastColon); - if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") return null; - const port = Number(localEndpoint.slice(lastColon + 1)); - if (!Number.isInteger(port) || port <= 0 || port >= 65536) return null; - const pid = Number(fields.at(-1)); - return { - host, - port, - pid: Number.isInteger(pid) && pid > 0 ? pid : null, - processName: null, - }; -}; - const exitedWithError = (result: ProcessRunner.ProcessRunOutput): boolean => result.code !== null && result.code !== 0; @@ -330,16 +282,13 @@ const parseTasklistProcessName = (raw: string, pid: number): string | null => { return null; }; -const parseWindowsListenerOutput = ( - raw: string, - owners: ReadonlyMap = new Map(), +const discoveredWindowsListeners = ( + listeners: ReadonlyArray, + owners: ReadonlyMap, ): ReadonlyArray => { const seen = new Map(); - for (const line of raw.split(/\r?\n/g)) { - const parsed = parseWindowsListenerLine(line); - if (!parsed) continue; - const { port, pid: normalizedPid, processName } = parsed; - const owner = normalizedPid === null ? undefined : owners.get(normalizedPid); + for (const { port, pid } of listeners) { + const owner = pid === null ? undefined : owners.get(pid); const existing = seen.get(port); if (existing) { if (owner === undefined) seen.set(port, { ...existing, ...UNOWNED }); @@ -349,8 +298,8 @@ const parseWindowsListenerOutput = ( host: "localhost", port, url: `http://localhost:${port}`, - processName, - pid: normalizedPid, + processName: null, + pid, ...(owner ?? UNOWNED), }); } @@ -857,21 +806,12 @@ export const make = Effect.gen(function* PortDiscoveryMake() { agentsPossible ? withAgentOwners(terminalOwners, pids) : Effect.succeed(terminalOwners); if (hostPlatform === "win32") { const recoverWindowsProbeFailure = recoverProcessProbeFailure("windows-listeners"); - const runNetstat = (protocol: "tcp" | "tcpv6") => - processRunner.run({ - command: "netstat.exe", - args: ["-ano", "-p", protocol], - timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), - maxOutputBytes: WINDOWS_NETSTAT_MAX_OUTPUT_BYTES, - }); - const listeners = yield* Effect.all([runNetstat("tcp"), runNetstat("tcpv6")], { - concurrency: 2, - }).pipe( - // Failed queries can hide other IPv4/IPv6 listeners on the port; fall back to configured URLs. - Effect.map(([tcp4, tcp6]) => - exitedWithError(tcp4) || exitedWithError(tcp6) - ? null - : parseWindowsListenerOutput(`${tcp4.stdout}\n${tcp6.stdout}`, terminalOwners), + const listeners = yield* WindowsTcpListeners.queryWindowsTcpListeners( + processRunner, + "netstat.exe", + ).pipe( + Effect.map((snapshot) => + snapshot === null ? null : discoveredWindowsListeners(snapshot, terminalOwners), ), Effect.catchTags({ ProcessSpawnError: recoverWindowsProbeFailure, diff --git a/apps/server/src/process/agentScope.test.ts b/apps/server/src/process/agentScope.test.ts index 2a9998f4cfbf6..0095146a3021f 100644 --- a/apps/server/src/process/agentScope.test.ts +++ b/apps/server/src/process/agentScope.test.ts @@ -127,8 +127,11 @@ const withFakeSystemd = ( ), ); -const unitOf = (launch: { readonly args: ReadonlyArray }) => - launch.args.find((arg) => arg.startsWith("--unit="))?.slice("--unit=".length) ?? ""; +const unitOf = (launch: Pick) => + launch.args + .map(String) + .find((arg) => arg.startsWith("--unit=")) + ?.slice("--unit=".length) ?? ""; const sliceOf = (launch: { readonly args: ReadonlyArray }) => launch.args.find((arg) => arg.startsWith("--slice="))?.slice("--slice=".length) ?? ""; diff --git a/apps/server/src/process/windowsTcpListeners.ts b/apps/server/src/process/windowsTcpListeners.ts new file mode 100644 index 0000000000000..789915fa4b193 --- /dev/null +++ b/apps/server/src/process/windowsTcpListeners.ts @@ -0,0 +1,61 @@ +import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; + +import type * as ProcessRunner from "../processRunner.ts"; + +const NETSTAT_TIMEOUT = Duration.seconds(5); +/** `netstat -ano` lists every connection, not just listeners; ~80 bytes per row, sized for ~200k rows. */ +const NETSTAT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; + +export interface WindowsTcpListener { + readonly port: number; + readonly pid: number | null; +} + +const parseWindowsTcpListenerLine = (line: string): WindowsTcpListener | null => { + // Foreign port `:0` identifies a listening TCP socket regardless of Windows locale. + const fields = line.trim().split(/\s+/); + if (fields.length < 4 || fields[0]?.toUpperCase() !== "TCP") return null; + const localEndpoint = fields[1] ?? ""; + const foreignEndpoint = fields[2] ?? ""; + if (!foreignEndpoint.endsWith(":0")) return null; + const lastColon = localEndpoint.lastIndexOf(":"); + if (lastColon <= 0) return null; + const host = localEndpoint.slice(0, lastColon); + if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") return null; + const port = Number(localEndpoint.slice(lastColon + 1)); + if (!Number.isInteger(port) || port <= 0 || port >= 65536) return null; + const pid = Number(fields.at(-1)); + return { port, pid: Number.isInteger(pid) && pid > 0 ? pid : null }; +}; + +const parseWindowsTcpListeners = (raw: string): ReadonlyArray => + raw.split(/\r?\n/g).flatMap((line) => { + const listener = parseWindowsTcpListenerLine(line); + return listener === null ? [] : [listener]; + }); + +const exitedWithError = (result: ProcessRunner.ProcessRunOutput): boolean => + result.code !== null && result.code !== 0; + +/** Resolves to `null` when either query exits nonzero. */ +export const queryWindowsTcpListeners = Effect.fn("windowsTcpListeners.query")(function* ( + processRunner: ProcessRunner.ProcessRunner["Service"], + netstatCommand: string, +) { + const runNetstat = (protocol: "tcp" | "tcpv6") => + processRunner.run({ + command: netstatCommand, + args: ["-ano", "-p", protocol], + timeout: NETSTAT_TIMEOUT, + maxOutputBytes: NETSTAT_MAX_OUTPUT_BYTES, + }); + const [tcp4, tcp6] = yield* Effect.all([runNetstat("tcp"), runNetstat("tcpv6")], { + concurrency: 2, + }); + // One failed query leaves a partial snapshot that could hide another process sharing a + // port across IPv4/IPv6. + if (exitedWithError(tcp4) || exitedWithError(tcp6)) return null; + return parseWindowsTcpListeners(`${tcp4.stdout}\n${tcp6.stdout}`); +}); diff --git a/apps/server/src/project/AgentSessionJson.test.ts b/apps/server/src/project/AgentSessionJson.test.ts index 515aa50b1b976..ea6f31f13c8af 100644 --- a/apps/server/src/project/AgentSessionJson.test.ts +++ b/apps/server/src/project/AgentSessionJson.test.ts @@ -48,7 +48,7 @@ describe("transcript JSON projection", () => { it("retains the import allocation and depth limits", () => { const limited = createTranscriptJsonReader( () => { - throw new TranscriptJsonLimitError("budget"); + throw new TranscriptJsonLimitError({ message: "budget" }); }, () => true, ); diff --git a/apps/server/src/project/AgentSessionJson.ts b/apps/server/src/project/AgentSessionJson.ts index 84b318f0642cc..c9dc6baa5f5e6 100644 --- a/apps/server/src/project/AgentSessionJson.ts +++ b/apps/server/src/project/AgentSessionJson.ts @@ -1,3 +1,4 @@ +import * as Schema from "effect/Schema"; import * as SchemaAST from "effect/SchemaAST"; import { none, type Many } from "stream-chain/defs.js"; import { Assembler } from "stream-json/core/assembler.js"; @@ -40,7 +41,10 @@ export function createTranscriptJsonSelector(schema: { readonly ast: SchemaAST.A return (path: JsonPath) => includes(ast, path, 0); } -export class TranscriptJsonLimitError extends Error {} +export class TranscriptJsonLimitError extends Schema.TaggedError()( + "TranscriptJsonLimitError", + { message: Schema.String }, +) {} export function createTranscriptJsonReader( reserve: (bytes: number) => void, @@ -150,7 +154,9 @@ export function createTranscriptJsonReader( for (const token of tokens.values) { if (token.name === "startObject" || token.name === "startArray") { if (++depth > (options?.maxDepth ?? 128)) - throw new TranscriptJsonLimitError("Transcript JSON nesting exceeds the depth limit"); + throw new TranscriptJsonLimitError({ + message: "Transcript JSON nesting exceeds the depth limit", + }); } else if (token.name === "endObject" || token.name === "endArray") { if (--depth === 0) complete = true; } diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 2c79aef7b8422..5a8e112766801 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -1,4 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off - FIFO gates must be released with a synchronous +// open on the main thread, because the Effect FileSystem queues behind the pinned libuv pool. import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import { describe, expect, it } from "@effect/vitest"; import { @@ -9,18 +13,22 @@ import { ProviderInstanceId, type ServerSettings as ContractServerSettings, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; @@ -50,10 +58,16 @@ interface ScannerTestInput { readonly importedWorkspaceRoots?: ReadonlyArray; readonly configBaseDir?: string; readonly providerInstances?: ContractServerSettings["providerInstances"]; + readonly processRunner?: Layer.Layer; } const layerScannerTest = (input: ScannerTestInput) => - AgentSessionScanner.layer.pipe( + (input.processRunner === undefined + ? AgentSessionScanner.layer + : Layer.effect(AgentSessionScanner.AgentSessionScanner, AgentSessionScanner.make).pipe( + Layer.provide(input.processRunner), + ) + ).pipe( Layer.provide( Layer.mergeAll( ServerSettings.layerTest({ @@ -248,6 +262,42 @@ const makeFileIdentityFixture = Effect.fn("AgentSessionScanner.test.makeFileIden }, ); +const LIBUV_POOL_SIZE = Number(process.env.UV_THREADPOOL_SIZE ?? 4); + +/** + * Opening a FIFO for reading waits in `open()` until a writer appears, like a + * TCC-protected file in a headless macOS daemon, and pins one libuv pool thread + * that fiber interruption cannot free. The finalizer opens it for writing on the + * main thread, which releases every waiting reader even with the pool exhausted. + */ +const makeOpenGate = Effect.fn("AgentSessionScanner.test.makeOpenGate")(function* ( + gatePath: string, +) { + NodeChildProcess.execFileSync("mkfifo", [gatePath]); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + try { + NodeFS.closeSync( + NodeFS.openSync(gatePath, NodeFS.constants.O_WRONLY | NodeFS.constants.O_NONBLOCK), + ); + } catch { + // ENXIO: no reader is waiting. + } + }), + ); +}); + +/** + * Resolves once a reader has opened the FIFO, so its read deadline is already + * scheduled on the TestClock. The writer stays open until the scope closes, so + * the reader sees no EOF and has to wait out that deadline. + */ +const awaitGateReader = (gatePath: string) => + Effect.acquireRelease( + Effect.promise(() => NodeFS.promises.open(gatePath, NodeFS.constants.O_WRONLY)), + (handle) => Effect.promise(() => handle.close()), + ); + it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { describe("scan", () => { it.effect("reads Claude project cwds from transcripts, newest first", () => @@ -1412,9 +1462,11 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { const claudeHomePath = yield* makeTempDir("t3code-record-budget-claude-"); const codexHomePath = yield* makeTempDir("t3code-record-budget-codex-"); const workspace = yield* makeTempDir("t3code-record-budget-workspace-"); - for (const [sessionId, padding, mtimeMs] of [ - ["excessive", "\n".repeat(100_001), nowMs], - ["older", "", nowMs - 1_000], + const exactLimit = makeRecordLimitTranscript(workspace, false); + // The newer transcript has 100,001 records; the older one has exactly 100,000. + for (const [sessionId, contents, mtimeMs] of [ + ["excessive", exactLimit.replace("record-limit-session", "excessive") + "{}\n", nowMs], + ["older", exactLimit, nowMs - 1_000], ] as const) { yield* writeTranscript({ filePath: path.join( @@ -1425,18 +1477,56 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { "24", `rollout-${sessionId}.jsonl`, ), + contents, + mtimeMs, + }); + } + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Skipped", "Importable"]); + expect(outcomes[1]).toMatchObject({ + thread: { providerSessionId: "record-limit-session" }, + }); + }), + ); + + it.effect("imports every transcript that fits the per-file record limit", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-shared-records-claude-"); + const codexHomePath = yield* makeTempDir("t3code-shared-records-codex-"); + const workspace = yield* makeTempDir("t3code-shared-records-workspace-"); + // Together these exceed 100,000 records, but each one is individually valid. + const recordCounts = [20_000, 20_000, 20_000, 58_000]; + for (const [index, recordCount] of recordCounts.entries()) { + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${index}.jsonl`, + ), contents: [ encodeTranscriptRecord({ type: "session_meta", - payload: { id: sessionId, cwd: workspace }, + payload: { id: `session-${index}`, cwd: workspace }, }), encodeTranscriptRecord({ type: "event_msg", payload: { type: "user_message", message: "Imported prompt" }, }), - ].join("\n") + padding, - mtimeMs, + ].join("\n") + + "\n" + + "{}\n".repeat(recordCount - 2), + mtimeMs: nowMs - index * 1_000, }); } const outcomes = yield* runRecentThreadOutcomes({ @@ -1444,8 +1534,11 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { codexHomePath, workspaceRoot: workspace, }); - expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Skipped", "Importable"]); - expect(outcomes[1]).toMatchObject({ thread: { providerSessionId: "older" } }); + expect( + outcomes.map((outcome) => + outcome._tag === "Importable" ? outcome.thread.providerSessionId : outcome._tag, + ), + ).toEqual(["session-0", "session-1", "session-2", "session-3"]); }), ); @@ -1811,6 +1904,282 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }); }); +describe.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "AgentSessionScanner scan with a git config open() that never returns", + () => { + it.effect( + "completes every scan and leaves the libuv pool free when .git/config is a FIFO", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const repo = yield* makeTempDir("t3code-workspace-fifo-config-"); + yield* fileSystem.makeDirectory(path.join(repo, ".git")); + yield* makeOpenGate(path.join(repo, ".git", "config")); + const transcriptPath = path.join(claudeHomePath, "projects", "-fifo", "a.jsonl"); + yield* writeTranscript({ + filePath: transcriptPath, + contents: claudeSessionLine(repo), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + + for (let attempt = 1; attempt <= LIBUV_POOL_SIZE + 1; attempt++) { + const scan = yield* Effect.forkChild( + runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.map(({ candidates }) => candidates.map(({ path }) => path)), + ), + ); + yield* Effect.scoped( + Effect.gen(function* () { + yield* awaitGateReader(path.join(repo, ".git", "config")); + yield* TestClock.adjust("2 seconds"); + expect(yield* Fiber.join(scan), `scan ${attempt}`).toEqual([repo]); + }), + ); + } + // Hangs if the scans left libuv pool threads stuck in open(). + yield* fileSystem.readFileString(transcriptPath); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("still reads the git identity of repositories next to a blocked one", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const repos = [ + yield* makeTempDir("t3code-workspace-before-"), + yield* makeTempDir("t3code-workspace-blocked-"), + yield* makeTempDir("t3code-workspace-after-"), + ] as const; + for (const [index, repo] of repos.entries()) { + yield* fileSystem.makeDirectory(path.join(repo, ".git")); + const configPath = path.join(repo, ".git", "config"); + if (index === 1) yield* makeOpenGate(configPath); + else + yield* fileSystem.writeFileString( + configPath, + `[remote "origin"]\n\turl = git@github.com:pingdotgg/repo-${index}.git\n`, + ); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"), + contents: claudeSessionLine(repo), + mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`), + }); + } + + // The deadline fires for every read still in flight, so on a slow runner the + // neighbours' `cat` children must have exited before the clock moves. + const neighbourConfigs = new Set( + [repos[0], repos[2]].map((repo) => path.join(repo, ".git", "config")), + ); + const finishedNeighbourReads = yield* Queue.unbounded(); + const processRunner = Layer.effect( + ProcessRunner.ProcessRunner, + Effect.gen(function* () { + const runner = yield* ProcessRunner.ProcessRunner; + return ProcessRunner.ProcessRunner.of({ + run: (input) => { + const filePath = input.args.at(-1) ?? ""; + return neighbourConfigs.has(filePath) + ? runner + .run(input) + .pipe(Effect.ensuring(Queue.offer(finishedNeighbourReads, filePath))) + : runner.run(input); + }, + }); + }), + ).pipe(Layer.provide(ProcessRunner.layer), Layer.provide(NodeServices.layer)); + + for (let attempt = 1; attempt <= 2; attempt++) { + const scan = yield* Effect.forkChild( + runScan({ claudeHomePath, codexHomePath, processRunner }), + ); + const result = yield* Effect.scoped( + Effect.gen(function* () { + yield* awaitGateReader(path.join(repos[1], ".git", "config")); + yield* Queue.take(finishedNeighbourReads); + yield* Queue.take(finishedNeighbourReads); + yield* TestClock.adjust("2 seconds"); + return yield* Fiber.join(scan); + }), + ); + expect( + result.candidates.map((candidate) => ({ path: candidate.path, git: candidate.git })), + `scan ${attempt}`, + ).toEqual([ + { + path: repos[2], + git: { remoteKey: "github.com/pingdotgg/repo-2", repository: "pingdotgg/repo-2" }, + }, + { path: repos[1], git: { remoteKey: null, repository: null } }, + { + path: repos[0], + git: { remoteKey: "github.com/pingdotgg/repo-0", repository: "pingdotgg/repo-0" }, + }, + ]); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("reads every blocked git config of a scan under one deadline", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const root = yield* makeTempDir("t3code-workspace-blocked-configs-"); + const repos = [path.join(root, "repo-a"), path.join(root, "repo-b")]; + for (const repo of repos) { + yield* fileSystem.makeDirectory(path.join(repo, ".git"), { recursive: true }); + yield* makeOpenGate(path.join(repo, ".git", "config")); + } + // Two linked worktrees of one bare repository share its blocked config. + const bareGitDir = path.join(root, "bare.git"); + yield* fileSystem.makeDirectory(bareGitDir); + yield* makeOpenGate(path.join(bareGitDir, "config")); + const worktrees = [path.join(root, "wt-1"), path.join(root, "wt-2")]; + for (const worktree of worktrees) { + const gitDir = path.join(bareGitDir, "worktrees", path.basename(worktree)); + yield* fileSystem.makeDirectory(gitDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(gitDir, "commondir"), "../..\n"); + yield* fileSystem.makeDirectory(worktree); + yield* fileSystem.writeFileString(path.join(worktree, ".git"), `gitdir: ${gitDir}\n`); + } + for (const [index, cwd] of [...repos, ...worktrees].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"), + contents: claudeSessionLine(cwd), + mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`), + }); + } + + const scan = yield* Effect.forkChild( + runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.map(({ candidates }) => candidates.map(({ path }) => path).toSorted()), + ), + ); + // Every blocked config is open before any deadline passes; reading them + // one after another would leave the later ones unopened. + yield* Effect.forEach( + [...repos.map((repo) => path.join(repo, ".git")), bareGitDir], + (gitDir) => awaitGateReader(path.join(gitDir, "config")), + { concurrency: "unbounded" }, + ); + yield* TestClock.adjust("2 seconds"); + expect(yield* Fiber.join(scan)).toEqual(repos); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("completes every scan when a .git pointer file or commondir never opens", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const root = yield* makeTempDir("t3code-workspace-blocked-pointers-"); + const pointerCheckout = path.join(root, "pointer"); + yield* fileSystem.makeDirectory(pointerCheckout); + yield* makeOpenGate(path.join(pointerCheckout, ".git")); + const worktree = path.join(root, "worktree"); + const worktreeGitDir = path.join(root, "main", ".git", "worktrees", "worktree"); + yield* fileSystem.makeDirectory(worktreeGitDir, { recursive: true }); + yield* makeOpenGate(path.join(worktreeGitDir, "commondir")); + yield* fileSystem.makeDirectory(worktree); + yield* fileSystem.writeFileString( + path.join(worktree, ".git"), + `gitdir: ${worktreeGitDir}\n`, + ); + for (const [index, cwd] of [pointerCheckout, worktree].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"), + contents: claudeSessionLine(cwd), + mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`), + }); + } + + // One scanner across scans: later scans skip the files that blocked. + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scanPaths = scanner.scan.pipe( + Effect.map(({ candidates }) => candidates.map(({ path }) => path)), + ); + const first = yield* Effect.forkChild(scanPaths); + // Pointer files are read first, then the commondir files they lead to. + for (const gatePath of [ + path.join(pointerCheckout, ".git"), + path.join(worktreeGitDir, "commondir"), + ]) { + yield* Effect.scoped( + Effect.gen(function* () { + yield* awaitGateReader(gatePath); + yield* TestClock.adjust("2 seconds"); + }), + ); + } + expect(yield* Fiber.join(first)).toEqual([pointerCheckout]); + for (let attempt = 2; attempt <= LIBUV_POOL_SIZE + 1; attempt++) { + expect(yield* scanPaths, `scan ${attempt}`).toEqual([pointerCheckout]); + } + }).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath }))); + // Hangs if the scans left libuv pool threads stuck in open(). + yield* fileSystem.readFileString(path.join(worktree, ".git")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("reads a once-blocked git config again after the retry interval", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const repo = yield* makeTempDir("t3code-workspace-unblocked-config-"); + yield* fileSystem.makeDirectory(path.join(repo, ".git")); + const configPath = path.join(repo, ".git", "config"); + yield* makeOpenGate(configPath); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-unblocked", "a.jsonl"), + contents: claudeSessionLine(repo), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + const gitOfScan = (scan: AgentSessionScanner.AgentSessionScanner["Service"]["scan"]) => + scan.pipe(Effect.map(({ candidates }) => candidates.map(({ git }) => git))); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const blockedScan = yield* Effect.forkChild(gitOfScan(scanner.scan)); + yield* Effect.scoped( + Effect.gen(function* () { + yield* awaitGateReader(configPath); + yield* TestClock.adjust("2 seconds"); + expect(yield* Fiber.join(blockedScan)).toEqual([ + { remoteKey: null, repository: null }, + ]); + }), + ); + + yield* fileSystem.remove(configPath); + yield* fileSystem.writeFileString( + configPath, + `[remote "origin"]\n\turl = git@github.com:pingdotgg/unblocked.git\n`, + ); + expect(yield* gitOfScan(scanner.scan), "a blocked path is skipped for a while").toEqual([ + { remoteKey: null, repository: null }, + ]); + + yield* TestClock.adjust("10 minutes"); + expect(yield* gitOfScan(scanner.scan)).toEqual([ + { remoteKey: "github.com/pingdotgg/unblocked", repository: "pingdotgg/unblocked" }, + ]); + }).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath }))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + }, +); + describe("parseAgentSessionTranscript", () => { it.each([false, true])( "handles the exact record limit and an interior blank overflow=%s", diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 10c94a5198751..a988c3d52d2ee 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -13,6 +13,7 @@ import { type AgentSessionScanResult, type ProviderInstanceConfig, } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -30,6 +31,7 @@ import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server/SourceControlProvider"; import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; @@ -60,6 +62,15 @@ function resolvePiSessionsDir( } const TRANSCRIPT_PREFIX_BYTES = 32 * 1024; + +class TranscriptParseError extends Schema.TaggedError()( + "TranscriptParseError", + { filePath: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not parse the transcript at ${this.filePath}.`; + } +} const METADATA_READ_BYTES = 8 * 1024; const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024; const MAX_CODEX_SESSION_INDEX_BYTES = 1024 * 1024; @@ -80,6 +91,21 @@ const MAX_IMPORT_BYTES = 4 * 1024 * 1024 * 1024; const MAX_IMPORT_TRANSCRIPTS = 100; const MAX_IMPORT_RECORDS = 100_000; +/** + * open() of a file under macOS privacy protection waits on a consent prompt + * that a headless server never gets, and a FIFO waits for a writer. Either + * pins a libuv pool thread that interruption cannot free, so git files of + * arbitrary transcript cwds are read by a `cat` child we can kill instead. + */ +const GIT_FILE_READ_DEADLINE = "2 seconds"; +const MAX_GIT_FILE_BYTES = 1024 * 1024; +// Reads run concurrently, so each phase waits one deadline per batch of this +// many blocked files rather than one per blocked file. +const GIT_FILE_READ_CONCURRENCY = 16; +// A blocked path is skipped for a while, then read again in case it was fixed. +const BLOCKED_GIT_FILE_RETRY_MS = 5 * 60 * 1000; +const MAX_BLOCKED_GIT_FILE_PATHS = 1024; + const TranscriptContentBlock = Schema.Struct({ type: Schema.optional(Schema.String), text: Schema.optional(Schema.String), @@ -734,6 +760,7 @@ function sameTranscriptIdentity(left: TranscriptIdentity, right: TranscriptIdent /** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const processRunner = yield* ProcessRunner.ProcessRunner; // Concurrent client imports must not hold multiple selected-history budgets. const importReadLock = yield* Semaphore.make(1); const path = yield* Path.Path; @@ -742,7 +769,8 @@ export const make = Effect.gen(function* () { const projectStore = yield* ProjectStore.ProjectStoreV2; const baseDir = path.resolve(serverConfig.baseDir); const worktreesDir = path.resolve(serverConfig.worktreesDir); - const foldWorktreeCase = (yield* HostProcess.Platform) === "win32"; + const isWindows = (yield* HostProcess.Platform) === "win32"; + const foldWorktreeCase = isWindows; const hostEnvironment = yield* HostProcess.Environment; const homeDir = yield* HostProcess.HomeDirectory; // `/private/tmp` is what macOS reports for sessions started in `/tmp`. @@ -789,48 +817,122 @@ export const make = Effect.gen(function* () { return `path:${normalizeProjectPathForComparison(realPath)}`; }); - const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* ( - directory: string, - ): Effect.fn.Return< - | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null } - | { readonly _tag: "Worktree" } - | { readonly _tag: "NotGit" } - > { - const gitPath = path.join(directory, ".git"); - const gitStats = yield* statOption(gitPath); - if (Option.isNone(gitStats)) return { _tag: "NotGit" } as const; - let gitDir = gitPath; - if (gitStats.value.type !== "Directory") { - const pointer = yield* fileSystem - .readFileString(gitPath) - .pipe(Effect.orElseSucceed(() => "")); - const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); - if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const; - gitDir = path.resolve(directory, target); + // Paths whose own read outlived the deadline, mapped to when they may be read + // again. Without this every later scan would wait out the deadline again for + // each phase that touches them. + const blockedGitFilePaths = new Map(); + + /** Reads small files without the libuv pool; "" when missing, unreadable or blocked. */ + const readGitFiles = Effect.fn("AgentSessionScanner.readGitFiles")(function* ( + filePaths: ReadonlyArray, + ) { + const unique = Array.from(new Set(filePaths)); + const now = yield* Clock.currentTimeMillis; + const contents = yield* Effect.forEach( + unique, + (filePath) => { + // No privacy prompts or FIFOs to block open() here. + if (isWindows) { + return fileSystem.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + } + if ((blockedGitFilePaths.get(filePath) ?? 0) > now) return Effect.succeed(""); + blockedGitFilePaths.delete(filePath); + return processRunner + .run({ + command: "cat", + args: ["--", filePath], + timeout: GIT_FILE_READ_DEADLINE, + timeoutBehavior: "timedOutResult", + maxOutputBytes: MAX_GIT_FILE_BYTES, + outputMode: "truncate", + }) + .pipe( + Effect.map((result) => { + if (result.timedOut) { + if (blockedGitFilePaths.size >= MAX_BLOCKED_GIT_FILE_PATHS) { + blockedGitFilePaths.delete(blockedGitFilePaths.keys().next().value!); + } + blockedGitFilePaths.set(filePath, now + BLOCKED_GIT_FILE_RETRY_MS); + } + return result.code === 0 ? result.stdout : ""; + }), + Effect.orElseSucceed(() => ""), + ); + }, + { concurrency: isWindows ? 1 : GIT_FILE_READ_CONCURRENCY }, + ); + return new Map(unique.map((filePath, index) => [filePath, contents[index]!])); + }); + + /** Reads every directory's git identity in three phases of concurrent reads. */ + const readGitIdentities = Effect.fn("AgentSessionScanner.readGitIdentities")(function* ( + directories: ReadonlyArray, + ) { + type GitIdentity = + | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null } + | { readonly _tag: "Worktree" } + | { readonly _tag: "NotGit" }; + const identities = new Map(); + const repositories: Array<{ directory: string; gitDir: string; linkedWorktree: boolean }> = []; + const pointers: Array<{ directory: string; gitPath: string }> = []; + for (const directory of directories) { + const gitPath = path.join(directory, ".git"); + const gitStats = yield* statOption(gitPath); + if (Option.isNone(gitStats)) identities.set(directory, { _tag: "NotGit" }); + else if (gitStats.value.type === "Directory") { + repositories.push({ directory, gitDir: gitPath, linkedWorktree: false }); + } else pointers.push({ directory, gitPath }); } - const linkedWorktree = - gitStats.value.type !== "Directory" && /[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir); - if (linkedWorktree) { - const commonDir = yield* fileSystem - .readFileString(path.join(gitDir, "commondir")) - .pipe(Effect.orElseSucceed(() => "")); - if (commonDir.trim().length === 0) return { _tag: "Worktree" } as const; - gitDir = path.resolve(gitDir, commonDir.trim()); + + const pointerTexts = yield* readGitFiles(pointers.map(({ gitPath }) => gitPath)); + const linkedWorktrees: Array<{ directory: string; gitDir: string }> = []; + for (const { directory, gitPath } of pointers) { + const target = /^gitdir:\s*(.+)$/m.exec(pointerTexts.get(gitPath) ?? "")?.[1]?.trim(); + if (target === undefined || target.length === 0) { + identities.set(directory, { _tag: "NotGit" }); + continue; + } + const gitDir = path.resolve(directory, target); + if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) { + linkedWorktrees.push({ directory, gitDir }); + } else repositories.push({ directory, gitDir, linkedWorktree: false }); } - const configText = yield* fileSystem - .readFileString(path.join(gitDir, "config")) - .pipe(Effect.orElseSucceed(() => "")); - // Bare repositories have no main checkout, so offer their linked worktrees independently. - if (linkedWorktree && !isBareGitConfig(configText)) return { _tag: "Worktree" } as const; - const originUrl = parseOriginUrlFromGitConfig(configText); - return { - _tag: "Repository", - git: { - remoteKey: - originUrl === null ? null : sourceControlDefinitions.repositoryKeyFromRemote(originUrl), - repository: repositoryPathFromRemoteUrl(originUrl), - }, - } as const; + + const commonDirTexts = yield* readGitFiles( + linkedWorktrees.map(({ gitDir }) => path.join(gitDir, "commondir")), + ); + for (const { directory, gitDir } of linkedWorktrees) { + const commonDir = (commonDirTexts.get(path.join(gitDir, "commondir")) ?? "").trim(); + if (commonDir.length === 0) identities.set(directory, { _tag: "Worktree" }); + else + repositories.push({ + directory, + gitDir: path.resolve(gitDir, commonDir), + linkedWorktree: true, + }); + } + + const configTexts = yield* readGitFiles( + repositories.map(({ gitDir }) => path.join(gitDir, "config")), + ); + for (const { directory, gitDir, linkedWorktree } of repositories) { + const configText = configTexts.get(path.join(gitDir, "config")) ?? ""; + // Bare repositories have no main checkout, so offer their linked worktrees independently. + if (linkedWorktree && !isBareGitConfig(configText)) { + identities.set(directory, { _tag: "Worktree" }); + continue; + } + const originUrl = parseOriginUrlFromGitConfig(configText); + identities.set(directory, { + _tag: "Repository", + git: { + remoteKey: + originUrl === null ? null : sourceControlDefinitions.repositoryKeyFromRemote(originUrl), + repository: repositoryPathFromRemoteUrl(originUrl), + }, + }); + } + return identities; }); const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( @@ -919,7 +1021,6 @@ export const make = Effect.gen(function* () { const readTranscript = Effect.fn("AgentSessionScanner.readTranscript")(function* ( filePath: string, expected: ReturnType, - recordLimit: number, source: AgentSessionSource, ) { if (expected.size > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; @@ -941,9 +1042,9 @@ export const make = Effect.gen(function* () { const reserve = (bytes: number) => { recordBytes += bytes; if (historyBytes + recordBytes > MAX_IMPORT_HISTORY_BYTES) { - throw new TranscriptJsonLimitError( - "Transcript selected history exceeds the 32 MiB memory budget", - ); + throw new TranscriptJsonLimitError({ + message: "Transcript selected history exceeds the 32 MiB memory budget", + }); } }; let reader = createTranscriptJsonReader(reserve, selectTranscriptPath); @@ -953,7 +1054,7 @@ export const make = Effect.gen(function* () { const finishRecord = () => { reader.write(decoder.decode()); recordCount += 1; - if (recordCount > recordLimit) return false; + if (recordCount > MAX_IMPORT_RECORDS) return false; const value = reader.finish(); const decoded = decodeTranscriptValue(value); if (Option.isNone(decoded) && source === "pi") { @@ -995,25 +1096,35 @@ export const make = Effect.gen(function* () { } bytesRead += next.value.byteLength; - const withinBudget = yield* Effect.try(() => { - let start = 0; - while (start < next.value.byteLength) { - const newline = next.value.indexOf(10, start); - const end = newline === -1 ? next.value.byteLength : newline; - recordStarted = true; - reader.write(decoder.decode(next.value.subarray(start, end), { stream: true })); - if (newline === -1) break; - if (!finishRecord()) return false; - start = newline + 1; - } - return true; + const withinBudget = yield* Effect.try({ + try: () => { + let start = 0; + while (start < next.value.byteLength) { + const newline = next.value.indexOf(10, start); + const end = newline === -1 ? next.value.byteLength : newline; + recordStarted = true; + reader.write(decoder.decode(next.value.subarray(start, end), { stream: true })); + if (newline === -1) break; + if (!finishRecord()) return false; + start = newline + 1; + } + return true; + }, + catch: (cause) => new TranscriptParseError({ filePath, cause }), }); if (!withinBudget) return null; } - if (recordStarted && !(yield* Effect.try(finishRecord))) return null; + if ( + recordStarted && + !(yield* Effect.try({ + try: finishRecord, + catch: (cause) => new TranscriptParseError({ filePath, cause }), + })) + ) + return null; return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) - ? { records, recordCount, cwd, claudeFields } + ? { records, cwd, claudeFields } : null; }), ), @@ -1420,6 +1531,30 @@ export const make = Effect.gen(function* () { git: AgentSessionProjectGit | null; } >(); + // Resolve every candidate directory first so one batch reads all git identities. + const directories = new Map>(); + for (const candidate of raw) { + const expanded = expandHomePath(candidate.cwd.trim(), homeDir); + if (!path.isAbsolute(expanded)) continue; + const resolved = path.resolve(expanded); + if (directories.has(resolved) || isExcludedProjectPath(resolved)) continue; + const stats = yield* statOption(resolved); + if (Option.isNone(stats) || stats.value.type !== "Directory") { + directories.set(resolved, Option.none()); + continue; + } + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + // Symlinks can disguise excluded worktree paths; re-check their targets. + directories.set(resolved, isExcludedProjectPath(realPath) ? Option.none() : stats); + } + const directoryGitIdentities = yield* readGitIdentities( + Array.from(directories).flatMap(([directory, stats]) => + Option.isSome(stats) ? [directory] : [], + ), + ); + const directoryKeys = new Map(); const gitIdentities = new Map(); @@ -1427,28 +1562,16 @@ export const make = Effect.gen(function* () { const expanded = expandHomePath(candidate.cwd.trim(), homeDir); if (!path.isAbsolute(expanded)) continue; const resolved = path.resolve(expanded); - if (isExcludedProjectPath(resolved)) continue; + const stats = directories.get(resolved); + if (stats === undefined || Option.isNone(stats)) continue; let key = directoryKeys.get(resolved); if (key === undefined) { - const stats = yield* statOption(resolved); - if (Option.isNone(stats) || stats.value.type !== "Directory") { - directoryKeys.set(resolved, ""); - continue; - } - const realPath = yield* fileSystem - .realPath(resolved) - .pipe(Effect.orElseSucceed(() => resolved)); - // Symlinks can disguise excluded worktree paths; re-check their targets. - if (isExcludedProjectPath(realPath)) { + const gitIdentity = directoryGitIdentities.get(resolved); + if (gitIdentity === undefined || gitIdentity._tag === "Worktree") { key = ""; } else { - const gitIdentity = yield* readGitIdentity(resolved); - if (gitIdentity._tag === "Worktree") { - key = ""; - } else { - key = yield* directoryIdentity(resolved, stats.value); - gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null); - } + key = yield* directoryIdentity(resolved, stats.value); + gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null); } directoryKeys.set(resolved, key); } @@ -1579,17 +1702,13 @@ export const make = Effect.gen(function* () { const codexThreadNamesByHome = new Map>(); let bytesRemaining = MAX_IMPORT_BYTES; let transcriptsRemaining = MAX_IMPORT_TRANSCRIPTS; - let recordsRemaining = MAX_IMPORT_RECORDS; return Stream.fromIteratorSucceed(eligibleTranscripts.values(), 1).pipe( Stream.mapEffect(({ candidate, transcript }) => Effect.gen(function* () { const completed = completedByFile.get( `${candidate.providerInstanceId}\0${transcript.filePath}`, ); - if ( - completed === undefined && - (transcriptsRemaining === 0 || bytesRemaining === 0 || recordsRemaining === 0) - ) { + if (completed === undefined && (transcriptsRemaining === 0 || bytesRemaining === 0)) { return Option.some({ _tag: "Skipped" }); } const stats = yield* statOption(transcript.filePath); @@ -1612,7 +1731,6 @@ export const make = Effect.gen(function* () { } if ( transcriptsRemaining === 0 || - recordsRemaining === 0 || identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES || identity.size > bytesRemaining ) { @@ -1620,16 +1738,10 @@ export const make = Effect.gen(function* () { } transcriptsRemaining -= 1; bytesRemaining -= identity.size; - const snapshot = yield* readTranscript( - transcript.filePath, - identity, - recordsRemaining, - candidate.source, - ); + const snapshot = yield* readTranscript(transcript.filePath, identity, candidate.source); if (snapshot === null) { return Option.some({ _tag: "Skipped" }); } - recordsRemaining -= snapshot.recordCount; if (Option.isNone(snapshot.cwd)) { return Option.some({ _tag: "Skipped" }); @@ -1696,4 +1808,6 @@ export const make = Effect.gen(function* () { return AgentSessionScanner.of({ scan, recentThreads }); }); -export const layer = Layer.effect(AgentSessionScanner, make); +export const layer = Layer.effect(AgentSessionScanner, make).pipe( + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index 86dbcc5beb618..bf6f43a3fdb30 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -1,6 +1,8 @@ import { assert, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, ProjectId, ThreadId, VcsProcessSpawnError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -76,7 +78,7 @@ const layer = (baseDir: string, options?: HarnessOptions) => Layer.provideMerge(SqlitePersistence.layerMemory), Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(ServerConfig.layerTest(baseDir, baseDir)), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const withScratch = ( @@ -96,7 +98,7 @@ const withScratch = ( const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-managed-folders-" }); return yield* body({ baseDir }).pipe(Effect.provide(layer(baseDir, options))); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); const git = (cwd: string, args: ReadonlyArray) => ProcessRunner.ProcessRunner.pipe( @@ -124,7 +126,7 @@ it.effect("offers nothing when the data dir sits inside a Git checkout", () => const failure = yield* Effect.flip(scratch.ensureScratchProject); assert.equal(failure._tag, "ScratchUnavailableError"); }).pipe(Effect.provide(layer(baseDir))); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("offers a Scratch folder when Git is not installed", () => @@ -305,25 +307,15 @@ const withGitEnv = ( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const emptyConfig = yield* fileSystem.makeTempFileScoped({ prefix: "t3-gitconfig-" }); - return yield* Effect.acquireUseRelease( - Effect.sync(() => { - const saved = GIT_IDENTITY_KEYS.map((key) => [key, process.env[key]] as const); - for (const key of GIT_IDENTITY_KEYS) delete process.env[key]; - Object.assign(process.env, { - GIT_CONFIG_GLOBAL: emptyConfig, - GIT_CONFIG_NOSYSTEM: "1", - ...env, - }); - return saved; + const hostEnv: NodeJS.ProcessEnv = { ...(yield* HostProcess.Environment) }; + for (const key of GIT_IDENTITY_KEYS) delete hostEnv[key]; + return yield* effect.pipe( + Effect.provideService(HostProcess.Environment, { + ...hostEnv, + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_CONFIG_NOSYSTEM: "1", + ...env, }), - () => effect, - (saved) => - Effect.sync(() => { - for (const [key, value] of saved) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }), ); }); @@ -348,6 +340,12 @@ it.effect("starts a named project as a committed repository, and suffixes a take const path = yield* Path.Path; const root = path.resolve(baseDir, "projects"); assert.equal(folders.namedProjectsRoot, root); + const attributes = path.join(baseDir, "crlf.gitattributes"); + const crlfConfig = path.join(baseDir, "crlf.gitconfig"); + yield* fileSystem.writeFileString(attributes, "* text=auto eol=crlf\n"); + yield* git(baseDir, ["config", "--file", crlfConfig, "core.safecrlf", "true"]); + yield* git(baseDir, ["config", "--file", crlfConfig, "core.attributesFile", attributes]); + (yield* HostProcess.Environment).GIT_CONFIG_GLOBAL = crlfConfig; const first = yield* folders.createNamedProject({ name: "Pinball Stats" }); const second = yield* folders.createNamedProject({ name: "pinball stats" }); @@ -420,7 +418,10 @@ it.effect("keeps a named project and reports why when Git cannot commit", () => path.join(baseDir, "no-identity.gitconfig"), "[user]\n\tuseConfigOnly = true\n", ); - process.env.GIT_CONFIG_GLOBAL = path.join(baseDir, "no-identity.gitconfig"); + (yield* HostProcess.Environment).GIT_CONFIG_GLOBAL = path.join( + baseDir, + "no-identity.gitconfig", + ); const result = yield* folders.createNamedProject({ name: "No Identity" }); diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index df6cb6f6b507e..4b0991e1a64ce 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -8,7 +8,7 @@ import { import { newProjectFolderName } from "@t3tools/shared/path"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -208,7 +208,7 @@ const make = Effect.gen(function* () { const gitWorkflow = yield* GitWorkflow.GitWorkflowService; const git = yield* GitVcsDriver.GitVcsDriver; const projects = yield* ProjectService.ProjectService; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; // Non-recursive mkdir atomically claims a name; racers move on after AlreadyExists. const claimFreeFolder = Effect.fnUntraced(function* ( @@ -261,8 +261,8 @@ const make = Effect.gen(function* () { ), ); yield* makeScratchFolder(workspaceRoot); - const id = yield* crypto - .randomUUIDv4() + const id = yield* random + .uuidV4() .pipe(Effect.mapError((cause) => new ScratchProjectError({ workspaceRoot, cause }))); const bootstrapped = yield* projects .bootstrap({ @@ -333,7 +333,7 @@ const make = Effect.gen(function* () { ? Effect.succeedSome(folderFor(id.slice(-8))) : attempt === 2 ? Effect.succeedSome(fullFolder) - : crypto.randomUUIDv4().pipe( + : random.uuidV4().pipe( Effect.map((uuid) => Option.some(`${fullFolder}-${uuid.slice(0, 8)}`)), Effect.mapError((cause) => new ScratchFolderError({ folder: fullFolder, cause })), ), @@ -395,11 +395,12 @@ const make = Effect.gen(function* () { path.join(cwd, "assets", "icon.svg"), namedProjectIconSvg(name), ); - // Force explicit filenames so global ignore rules cannot drop them. + // Force explicit filenames so global ignore rules cannot drop them, and keep + // core.safecrlf=true from refusing the LF files under CRLF conversion. yield* git.execute({ operation: "ManagedProjectFolders.add", cwd, - args: ["add", "--force", "--", "README.md", "assets/icon.svg"], + args: ["-c", "core.safecrlf=false", "add", "--force", "--", "README.md", "assets/icon.svg"], timeoutMs: 10_000, }); return yield* git @@ -427,8 +428,8 @@ const make = Effect.gen(function* () { scaffoldRepository(workspaceRoot, input.name).pipe( Effect.mapError((cause) => new NamedProjectFolderError({ folder: workspaceRoot, cause })), ), - crypto - .randomUUIDv4() + random + .uuidV4() .pipe(Effect.mapError((cause) => new NamedProjectCreateError({ workspaceRoot, cause }))), ]).pipe(Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : removeFolder))); const [commitError, id] = prepared; diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index f67ec6e53bb4d..538beed6f1f06 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -210,63 +210,67 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( { concurrency: "unbounded", discard: true }, ); - const requestLane = Effect.fn("ProjectEnrichmentService.requestLane")(function* ( + // Every project read lands here; only an actual enqueue gets a span, the resolve workers trace the work. + const requestLane = Effect.fnUntraced(function* ( lane: EnrichmentWorkLane, workspaceRoot: string, field: "repositoryIdentity" | "faviconPath", ) { if (!(yield* reservePending(lane, workspaceRoot))) return; - if (!(yield* Queue.offer(lane.queue, workspaceRoot))) { - yield* removePending(lane, workspaceRoot); - yield* Effect.logWarning("Project metadata enrichment queue is full", { - workspaceRoot, - field, - }); - } + yield* Effect.gen(function* () { + if (!(yield* Queue.offer(lane.queue, workspaceRoot))) { + yield* removePending(lane, workspaceRoot); + yield* Effect.logWarning("Project metadata enrichment queue is full", { + workspaceRoot, + field, + }); + } + }).pipe(Effect.withSpan("ProjectEnrichmentService.requestLane")); }); - const peek: ProjectEnrichmentService["Service"]["peek"] = Effect.fn( - "ProjectEnrichmentService.peek", - )(function* (workspaceRoot) { - const [repositoryIdentity, faviconPath] = yield* Effect.all( - [ - Cache.getSuccess(repositoryIdentityCache, workspaceRoot), - Cache.getSuccess(faviconCache, workspaceRoot), - ] as const, - { concurrency: "unbounded" }, - ); - return { - repositoryIdentity: availableValue(repositoryIdentity), - faviconPath: availableValue(faviconPath), - repositoryIdentityResolved: isSuccessfullyResolved(repositoryIdentity), - }; - }); + // peek/request/getAvailable run per project on every shell read, inside the caller's span; own spans were ~0ms noise. + const peek: ProjectEnrichmentService["Service"]["peek"] = Effect.fnUntraced( + function* (workspaceRoot) { + const [repositoryIdentity, faviconPath] = yield* Effect.all( + [ + Cache.getSuccess(repositoryIdentityCache, workspaceRoot), + Cache.getSuccess(faviconCache, workspaceRoot), + ] as const, + { concurrency: "unbounded" }, + ); + return { + repositoryIdentity: availableValue(repositoryIdentity), + faviconPath: availableValue(faviconPath), + repositoryIdentityResolved: isSuccessfullyResolved(repositoryIdentity), + }; + }, + ); - const request: ProjectEnrichmentService["Service"]["request"] = Effect.fn( - "ProjectEnrichmentService.request", - )(function* (workspaceRoot) { - const [hasRepositoryIdentity, hasFaviconPath] = yield* Effect.all( - [Cache.has(repositoryIdentityCache, workspaceRoot), Cache.has(faviconCache, workspaceRoot)], - { concurrency: "unbounded" }, - ); - yield* Effect.all( - [ - hasRepositoryIdentity - ? Effect.void - : requestLane(repositoryIdentityLane, workspaceRoot, "repositoryIdentity"), - hasFaviconPath ? Effect.void : requestLane(faviconLane, workspaceRoot, "faviconPath"), - ], - { concurrency: "unbounded", discard: true }, - ); - }); + const request: ProjectEnrichmentService["Service"]["request"] = Effect.fnUntraced( + function* (workspaceRoot) { + const [hasRepositoryIdentity, hasFaviconPath] = yield* Effect.all( + [Cache.has(repositoryIdentityCache, workspaceRoot), Cache.has(faviconCache, workspaceRoot)], + { concurrency: "unbounded" }, + ); + yield* Effect.all( + [ + hasRepositoryIdentity + ? Effect.void + : requestLane(repositoryIdentityLane, workspaceRoot, "repositoryIdentity"), + hasFaviconPath ? Effect.void : requestLane(faviconLane, workspaceRoot, "faviconPath"), + ], + { concurrency: "unbounded", discard: true }, + ); + }, + ); - const getAvailable: ProjectEnrichmentService["Service"]["getAvailable"] = Effect.fn( - "ProjectEnrichmentService.getAvailable", - )(function* (workspaceRoot) { - const available = yield* peek(workspaceRoot); - yield* request(workspaceRoot); - return available; - }); + const getAvailable: ProjectEnrichmentService["Service"]["getAvailable"] = Effect.fnUntraced( + function* (workspaceRoot) { + const available = yield* peek(workspaceRoot); + yield* request(workspaceRoot); + return available; + }, + ); const readRepositoryIdentity: ProjectEnrichmentService["Service"]["readRepositoryIdentity"] = Effect.fn("ProjectEnrichmentService.readRepositoryIdentity")(function* (workspaceRoot) { diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index 41804842d5205..28b1c40541ddf 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -22,7 +22,7 @@ import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as EventStore from "../orchestration-v2/EventStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; -import * as ProjectionMaintenance from "../orchestration-v2/ProjectionMaintenance.ts"; +import * as ProjectionReplay from "../orchestration-v2/testkit/ProjectionReplay.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; @@ -40,7 +40,7 @@ const layerEventPersistence = EventSink.layer.pipe( ); const layerServices = Layer.mergeAll( LegacyV1ThreadImporter.layer.pipe(Layer.provideMerge(layerEventPersistence)), - ProjectionMaintenance.layer.pipe(Layer.provide(layerEventPersistence)), + ProjectionReplay.layer.pipe(Layer.provide(layerEventPersistence)), ProjectStore.layer, IdAllocator.layer, ThreadCommandExecutor.layer, @@ -310,7 +310,7 @@ it.effect( yield* Effect.gen(function* () { const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const projections = yield* ProjectionStore.ProjectionStoreV2; const service = yield* ProjectService.make; assert.deepEqual(yield* importer.reconcileShells, { diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 10f4a138ca23b..a48ef925f8b60 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -1,7 +1,9 @@ +import * as Arr from "effect/Array"; import * as Context from "effect/Context"; import * as Queue from "effect/Queue"; import * as Tracer from "effect/Tracer"; import { assert, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, type Project, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -97,7 +99,7 @@ const layerTestFor = ( Layer.provideMerge(SqlitePersistence.layerMemory), Layer.provideMerge(ServerSettings.layerTest()), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-test-" })), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(layerEnrichmentCompletions), ); @@ -133,7 +135,7 @@ it.effect("resolves only the requested active project page in creation order", ( const service = yield* ProjectService.ProjectService; const enrichment = yield* ProjectEnrichmentService.ProjectEnrichmentService; const changes = yield* enrichment.subscribeChanges; - const rows = Array.from({ length: 6 }, (_, index) => ({ + const rows = Arr.makeBy(6, (index) => ({ project_id: `page:${index}`, title: `Project ${index}`, workspace_root: `/work/page-${index}`, @@ -199,7 +201,7 @@ const layerProjectServiceDependencies = Layer.mergeAll( Layer.provideMerge(SqlitePersistence.layerMemory), Layer.provideMerge(ServerSettings.layerTest()), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-race-" })), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const waitForProject = Effect.fn("ProjectServiceTest.waitForProject")(function* ( diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index ec11ce9840448..96b574f2f8955 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -9,7 +9,7 @@ import { import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -199,7 +199,7 @@ export const make = Effect.gen(function* () { const threads = yield* ProjectionStore.ProjectionStoreV2; const terminalManager = yield* TerminalManager.TerminalManager; const serverSettings = yield* ServerSettings.ServerSettingsService; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const serviceScope = yield* Effect.scope; const observeTerminalCompletion = Effect.fn("ProjectSetupScriptRunner.observeTerminalCompletion")( @@ -430,7 +430,7 @@ export const make = Effect.gen(function* () { // Earlier settle shells may still run foreground programs; do not type resumed settles into them. const terminalId = trigger === "settle" - ? `settle-${script.id}-${(yield* crypto.randomUUIDv4().pipe(Effect.orDie)).slice(0, 8)}` + ? `settle-${script.id}-${(yield* random.uuidV4()).slice(0, 8)}` : `setup-${script.id}`; const cwd = input.worktreePath; const env = { @@ -442,9 +442,7 @@ export const make = Effect.gen(function* () { COLORTERM: "", }; const observe = input.observeCompletion; - const completionToken = observe - ? (yield* crypto.randomUUIDv4().pipe(Effect.orDie)).replaceAll("-", "") - : null; + const completionToken = observe ? (yield* random.uuidV4()).replaceAll("-", "") : null; const echoedWrapperLines: string[] = []; // Subscribe before opening so neither startup activity nor the sentinel can race the listeners. const observed = diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 1dee261c0302e..5a6a385aee82e 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -167,6 +167,31 @@ describe("RepositoryIdentityResolver", () => { }), ); + // Coder: upstream's userless SSH remote case (#29457) on a GitLab remote, mocked like the tests + // above. + it.effect("identifies the host of an SSH remote that leaves its user to ssh config", () => + Effect.gen(function* () { + const process = ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.succeed( + input.args.includes("--show-toplevel") + ? output("/workspace/project\n") + : output("origin\tgitlab.example.gs.com:Goldman/Smoke.git (fetch)\n"), + ), + }); + const resolver = yield* make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, process), + Effect.provide(existingFolders), + ); + + const identity = yield* resolver.resolve("/workspace/project"); + + expect(identity?.canonicalKey).toBe("gitlab.example.gs.com/goldman/smoke"); + expect(identity?.provider).toBe("gitlab"); + expect(identity?.displayName).toBe("Goldman/Smoke"); + }), + ); + // Coder: upstream's partial-clone case on a GitLab remote, mocked like the tests above. it.effect("resolves the fetch remote of a partial clone", () => Effect.gen(function* () { @@ -321,6 +346,6 @@ describe("RepositoryIdentityResolver", () => { expect(bareProbes()).toEqual([]); expect(yield* resolver.resolve(gitDir)).toBeNull(); - }).pipe(Effect.provide(ProcessRunner.layer), Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer)))), ); }); diff --git a/apps/server/src/project/ScratchThreadPromotion.ts b/apps/server/src/project/ScratchThreadPromotion.ts index 671a480a95832..f8d28a9fe007b 100644 --- a/apps/server/src/project/ScratchThreadPromotion.ts +++ b/apps/server/src/project/ScratchThreadPromotion.ts @@ -5,7 +5,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -48,7 +48,7 @@ const make = Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; const threads = yield* ThreadManagementService.ThreadManagementService; const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const path = yield* Path.Path; const fail = (reason: string) => (cause: unknown) => @@ -128,9 +128,7 @@ const make = Effect.gen(function* () { ); }); - const randomId = crypto - .randomUUIDv4() - .pipe(Effect.mapError(fail("Could not create an id for the project."))); + const randomId = random.uuidV4(); // bootstrap returns the project that already owns the folder, so retries reuse it. const createProject = Effect.fn("ScratchThreadPromotion.createProject")(function* ( diff --git a/apps/server/src/project/configMetadata.test.ts b/apps/server/src/project/configMetadata.test.ts index 8c3770d559a1d..e8f4e95bcf103 100644 --- a/apps/server/src/project/configMetadata.test.ts +++ b/apps/server/src/project/configMetadata.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - Coder: plain vitest fixtures for the synchronous config reader. import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; diff --git a/apps/server/src/project/configMetadata.ts b/apps/server/src/project/configMetadata.ts index 286795e1f3116..c197785ee3310 100644 --- a/apps/server/src/project/configMetadata.ts +++ b/apps/server/src/project/configMetadata.ts @@ -12,7 +12,7 @@ import { * Coder: return only fields Coder honors. Project icons never come from repository paths, and * there is no in-app preview, so `iconPath` and script preview fields are dropped. */ -function supportedProjectConfig(file: typeof T3ProjectFile.Type): typeof T3ProjectFile.Type { +function supportedProjectConfig(file: T3ProjectFile): T3ProjectFile { const { iconPath: _iconPath, ...rest } = file; return { ...rest, @@ -79,7 +79,7 @@ export function readProjectConfig(workspaceRoot: string): ProjectGetConfigResult } const text = readConfigMetadata(filePath, PROJECT_CONFIG_MAX_BYTES, root); if (text === null) return { status: "invalid", file: null }; - const decoded = Schema.decodeUnknownExit(Schema.fromJsonString(T3ProjectFile))(text); + const decoded = Schema.decodeExit(Schema.fromJsonString(T3ProjectFile))(text); return decoded._tag === "Success" ? { status: "valid", file: supportedProjectConfig(decoded.value) } : { status: "invalid", file: null }; diff --git a/apps/server/src/provider/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index 1599371245cb8..d83be92680ae4 100644 --- a/apps/server/src/provider/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -5,7 +5,9 @@ import * as TestClock from "effect/testing/TestClock"; import * as Fiber from "effect/Fiber"; import * as Deferred from "effect/Deferred"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { ProviderInstanceId } from "@t3tools/contracts"; import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; import { describe, expect, it } from "@effect/vitest"; @@ -348,7 +350,7 @@ describe("ACP Registry probe", () => { reason: "probe_failed", message: expect.stringContaining("within 60 seconds"), }); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped), ); // Coder: the registry catalog resolves only a local command, and logout is not carried. @@ -392,7 +394,7 @@ describe("ACP Registry probe", () => { }); }).pipe( Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped, ); }); diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index e98a8276510e9..ce35af129d901 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -3,13 +3,18 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ClaudeSettings, type ServerProvider } from "@t3tools/contracts"; import { vi } from "vite-plus/test"; import { assert, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { buildClaudeCapabilitiesProbeQueryOptions, + probeClaudeCapabilities, probeClaudeWorkspaceSnapshot, } from "./ClaudeProvider.ts"; +import * as ClaudeCli from "./Drivers/ClaudeCli.ts"; // Coder: the probe runs the workspace CLI transport, not the SDK query. vi.mock("./Drivers/ClaudeCli.ts", { spy: true }); @@ -42,3 +47,46 @@ it.effect("reports a deleted worktree's scan as finished, not pending", () => assert.strictEqual(snapshot.slashCommandsPending, false); }).pipe(Effect.provide(NodeServices.layer)), ); + +it.effect("keeps the probe subprocess alive while gateway model discovery can finish", () => + Effect.gen(function* () { + let abortSignal: AbortSignal | undefined; + // Coder: the probe's query is the workspace CLI transport's, which reads usage with `getUsage`. + const query = vi.spyOn(ClaudeCli, "query").mockImplementation(({ options }) => { + abortSignal = options?.abortController?.signal; + return { + initializationResult: async () => ({ + account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, + commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], + models: [{ value: "gateway/claude-relay", displayName: "Relay", description: "" }], + }), + getSettings: async () => ({}), + getUsage: () => new Promise((_, reject) => reject(new Error("usage unavailable"))), + } as unknown as ReturnType; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const fileSystem = yield* FileSystem.FileSystem; + const configDir = yield* fileSystem.makeTempDirectoryScoped(); + // Claude Code also reads these from settings.json, which is where gateway users set them. + yield* fileSystem.writeFileString( + `${configDir}/settings.json`, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: "https://gateway.example", + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1", + }, + }), + ); + const capabilities = yield* probeClaudeCapabilities( + yield* decodeClaudeSettings({ binaryPath: "claude" }), + { PATH: (yield* HostProcess.Environment).PATH, CLAUDE_CONFIG_DIR: configDir }, + ); + assert.deepEqual( + capabilities.models?.map((model) => model.value), + ["gateway/claude-relay"], + ); + assert.equal(abortSignal?.aborted, false); + yield* TestClock.adjust("3 seconds"); + assert.equal(abortSignal?.aborted, true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index f67628efb83a3..d4fba4253866b 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -6,6 +6,7 @@ import { type ServerProviderModel, type ServerProviderUpdateRequiredModel, } from "@t3tools/contracts"; +import type { ModelInfo as ClaudeModelInfo } from "@anthropic-ai/claude-agent-sdk"; import * as Option from "effect/Option"; import { getModelSelectionStringOptionValue, @@ -114,6 +115,66 @@ export function scopeClaudeModelCatalog( return { models: [...builtInModels, ...customCatalogModels] }; } +const CLAUDE_EFFORT_LABELS: Readonly> = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +}; + +function stripClaudeContextWindowSuffix(value: string): string { + return value.replace(/\[[^\]]*\]$/, "").trim(); +} + +export function claudeReportedModels( + catalog: ClaudeModelCatalog, + customModels: ReadonlyArray, + reported: ReadonlyArray | undefined, +): ReadonlyArray { + if (!reported?.length) return []; + const known = new Set( + [ + ...catalog.models.flatMap(({ model }) => [model.slug, ...(model.aliases ?? [])]), + ...readCustomModelEntries(customModels).map((entry) => entry.slug), + ].map((id) => stripClaudeContextWindowSuffix(id).toLowerCase()), + ); + const models: Array = []; + for (const info of reported) { + const slug = info.value.trim(); + // `default` is Claude Code's pointer at its own default model, not a model. + if (!slug || slug === "default") continue; + const ids = [slug, info.resolvedModel ?? ""] + .map((id) => stripClaudeContextWindowSuffix(id).toLowerCase()) + .filter((id) => id.length > 0); + if (ids.some((id) => known.has(id))) continue; + for (const id of ids) known.add(id); + const efforts = info.supportsEffort ? (info.supportedEffortLevels ?? []) : []; + models.push({ + slug, + name: info.displayName.trim() || slug, + isCustom: false, + capabilities: { + optionDescriptors: efforts.length + ? [ + { + id: "effort", + label: "Reasoning", + type: "select", + // `default` is not an effort, so choosing it sends none and Claude Code picks. + options: [ + { id: "default", label: "Default", isDefault: true }, + ...efforts.map((id) => ({ id, label: CLAUDE_EFFORT_LABELS[id] ?? id })), + ], + }, + ] + : [], + }, + }); + } + return models; +} + function resolveClaudeCatalogModel( catalog: ClaudeModelCatalog, slugOrAlias: string | null | undefined, diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 80f9952fbf714..ea9ca1466c6e6 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -13,11 +13,13 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { query as claudeQuery, + type ModelInfo as ClaudeModelInfo, type Options as ClaudeQueryOptions, type SlashCommand as ClaudeSlashCommand, type SDKUserMessage, @@ -49,6 +51,7 @@ import { import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, + claudeReportedModels, formatClaudeVersionUpgradeMessage, resolveClaudeModelsForVersion, resolveClaudeUpdateRequiredModels, @@ -251,6 +254,7 @@ export type ClaudeCapabilitiesProbe = { readonly autoModeDisabled: boolean; readonly bypassPermissionsDisabled: boolean; readonly slashCommands: ReadonlyArray; + readonly models?: ReadonlyArray; /** Undefined means get_usage failed; absent windows on success mean no subscription windows, as with API keys. */ readonly usage?: Pick; readonly usageCheckedAt?: string; @@ -346,6 +350,30 @@ function waitForAbortSignal(signal: AbortSignal): Promise { }); } +/** + * With gateway model discovery on, Claude Code refreshes its model cache in the background after + * initialization, so the probe subprocess outlives init by Claude Code's discovery timeout. + */ +function claudeGatewayDiscoveryGraceMs(environment: Readonly>): number { + const enabled = String(environment.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY ?? "") + .trim() + .toLowerCase(); + if (!enabled || enabled === "0" || enabled === "false") return 0; + if (!String(environment.ANTHROPIC_BASE_URL ?? "").trim()) return 0; + const configured = Number(environment.CLAUDE_CODE_GATEWAY_MODEL_DISCOVERY_TIMEOUT_MS); + return Number.isFinite(configured) && configured > 0 ? configured : 3_000; +} + +class ClaudeCapabilitiesProbeSdkError extends Schema.TaggedError()( + "ClaudeCapabilitiesProbeSdkError", + // Coder: `settings` reads the effective permission policy through the CLI transport. + { operation: Schema.Literals(["initialize", "settings", "usage"]), cause: Schema.Defect() }, +) { + override get message(): string { + return `Claude capability probe ${this.operation} failed.`; + } +} + /** A never-yielding prompt allows local initialization IPC without sending an Anthropic request. */ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, @@ -354,48 +382,63 @@ const probeClaudeCapabilities = ( includeUsage = true, ) => { const abort = new AbortController(); + const abortProbe = Effect.sync(() => { + if (!abort.signal.aborted) abort.abort(); + }); return Effect.gen(function* () { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const executablePath = claudeSettings.binaryPath; const managedMcpConfig = yield* hasClaudeManagedMcpConfig(); - return yield* Effect.tryPromise(async () => { - const q = claudeQuery({ - // oxlint-disable-next-line require-yield -- never yields, so no prompt reaches the API - prompt: (async function* (): AsyncGenerator { - await waitForAbortSignal(abort.signal); - })(), - options: buildClaudeCapabilitiesProbeQueryOptions({ - executablePath, - abortController: abort, - environment: claudeEnvironment, - cwd, - managedMcpConfig, - }), - }); - const init = await q.initializationResult(); - return { q, init }; + // Workspace probes (no usage) discard models; the machine probe refreshes the shared cache. + const graceMs = includeUsage + ? claudeGatewayDiscoveryGraceMs({ + ...claudeEnvironment, + ...(yield* readClaudeSettingsEnv(claudeSettings, cwd, claudeEnvironment)), + }) + : 0; + return yield* Effect.tryPromise({ + try: async () => { + const q = claudeQuery({ + // oxlint-disable-next-line require-yield -- never yields, so no prompt reaches the API + prompt: (async function* (): AsyncGenerator { + await waitForAbortSignal(abort.signal); + })(), + options: buildClaudeCapabilitiesProbeQueryOptions({ + executablePath, + abortController: abort, + environment: claudeEnvironment, + cwd, + managedMcpConfig, + }), + }); + const init = await q.initializationResult(); + return { q, init, graceMs }; + }, + catch: (cause) => new ClaudeCapabilitiesProbeSdkError({ operation: "initialize", cause }), }); }).pipe( Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS), - Effect.flatMap(({ q, init }) => + Effect.flatMap(({ q, init, graceMs }) => Effect.gen(function* () { // Coder: read the effective permission policy before usage; a CLI that cannot report it // fails closed below. - const settings = yield* Effect.tryPromise(() => - q.getSettings(CAPABILITIES_SETTINGS_TIMEOUT_MS), - ).pipe(Effect.option); + const settings = yield* Effect.tryPromise({ + try: () => q.getSettings(CAPABILITIES_SETTINGS_TIMEOUT_MS), + catch: (cause) => new ClaudeCapabilitiesProbeSdkError({ operation: "settings", cause }), + }).pipe(Effect.option); // Usage has its own deadline so a slow optional request cannot discard initialization. // Only the rate limits are read, so skip the local transcript scan that fills // `behaviors`: with a few GB of transcripts it outlasts the deadline. const usageCheckedAt = DateTime.formatIso(yield* DateTime.now); const usageResult = includeUsage - ? yield* Effect.tryPromise( + ? yield* Effect.tryPromise({ // Coder: the workspace CLI transport answers the SDK's usage control request. - () => + try: () => q.getUsage({ skipBehaviors: true, }) as unknown as Promise, - ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result) + catch: (cause) => new ClaudeCapabilitiesProbeSdkError({ operation: "usage", cause }), + }).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result) : undefined; const usage = usageResult && Result.isSuccess(usageResult) @@ -426,15 +469,18 @@ const probeClaudeCapabilities = ( Option.isNone(settings) || readClaudePermissionModeDisabled(settings.value, "disableBypassPermissionsMode"), slashCommands: parseClaudeInitializationCommands(init.commands), + ...(init.models?.length ? { models: init.models } : {}), ...(usage ? { usage, usageCheckedAt } : {}), } satisfies ClaudeCapabilitiesProbe; - }), - ), - Effect.ensuring( - Effect.sync(() => { - if (!abort.signal.aborted) abort.abort(); - }), + }).pipe( + Effect.tap(() => + graceMs > 0 + ? Effect.sleep(graceMs).pipe(Effect.andThen(abortProbe), Effect.forkDetach) + : abortProbe, + ), + ), ), + Effect.onError(() => abortProbe), Effect.tapError((error) => Effect.logWarning("Claude capability probe failed.", { errorTag: error._tag }), ), @@ -606,7 +652,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const probeFailure = probeResult && Result.isFailure(probeResult) ? probeResult.failure : undefined; const models = providerModelsFromSettings( - resolveClaudeModelsForVersion(modelCatalog, parsedVersion), + [ + ...resolveClaudeModelsForVersion(modelCatalog, parsedVersion), + ...claudeReportedModels(modelCatalog, claudeSettings.customModels, capabilities?.models), + ], claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/ClaudeTurnTokenUsage.ts b/apps/server/src/provider/ClaudeTurnTokenUsage.ts index 2fa6a11bac1e5..118716c55c26d 100644 --- a/apps/server/src/provider/ClaudeTurnTokenUsage.ts +++ b/apps/server/src/provider/ClaudeTurnTokenUsage.ts @@ -77,6 +77,19 @@ export function claudeModelLedger(modelUsage: unknown): ClaudeModelLedger | unde return ledger; } +/** The window each model in the SDK's `modelUsage` is measured against, as Claude Code resolved it. */ +export function claudeReportedContextWindows(modelUsage: unknown): ReadonlyMap { + const windows = new Map(); + if (typeof modelUsage !== "object" || modelUsage === null) return windows; + for (const [model, entry] of Object.entries(modelUsage as Record)) { + const window = finiteNonNegativeInteger( + (entry as Record | null)?.contextWindow, + ); + if (window !== undefined && window > 0) windows.set(model, window); + } + return windows; +} + /** Ledger growth since `previous`. Unknown without a baseline or when a counter goes backwards. */ export function claudeLedgerDelta( previous: ClaudeModelLedger | undefined, diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index f96116dee0dea..2b24d4a2ccd4d 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -89,7 +89,10 @@ describe("Codex orchestration context", () => { NodeAssert.ok(orchestration.includes("use your native tool when the child uses your provider")); NodeAssert.ok(orchestration.includes("`run_in_background:false`")); - NodeAssert.ok(orchestration.includes("A native type running another provider's model")); + NodeAssert.ok(orchestration.includes("A native type on another provider's model")); + NodeAssert.ok( + orchestration.includes("Background agent: `delegate_task`, never a background `Agent`."), + ); NodeAssert.ok( orchestration.includes("Every new delegate review or re-check is a new `delegate_task`"), ); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 1fc875567c94e..2adec72f9523b 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -588,12 +588,11 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun .pipe(Effect.flatMap(decodeCodexRuntimeRequirementsResponse)), deadlineMs, CODEX_CONFIG_PROBE_TIMEOUT_MS, - ).pipe(Effect.catch(() => Effect.succeed(undefined))), + ).pipe(Effect.orElseSucceed(() => undefined)), // Usage is an enrichment: a failure or a slow answer degrades to "no // usage this probe" rather than costing the account and models. input.skipNativeUsage - ? // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. - Effect.succeed(undefined) + ? Effect.undefined : withCodexDiscoveryDeadline( client.request("account/rateLimits/read", null), deadlineMs, diff --git a/apps/server/src/provider/CodexToolPresentation.ts b/apps/server/src/provider/CodexToolPresentation.ts index 323d068aa067e..19286c6fd1b4f 100644 --- a/apps/server/src/provider/CodexToolPresentation.ts +++ b/apps/server/src/provider/CodexToolPresentation.ts @@ -3,7 +3,7 @@ import type { ToolActivityNativeAppReference, ToolActivitySource, } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import type * as EffectCodexSchema from "effect-codex-app-server/schema"; @@ -49,9 +49,9 @@ const nativeAppSourceKey = Effect.fn("CodexToolPresentation.nativeAppSourceKey") ) { const key = `native-app:${appId.toLowerCase()}`; if (key.length <= 512) return key; - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const digest = Hex.encode( - yield* crypto.digest("SHA-256", new TextEncoder().encode(key)).pipe(Effect.orDie), + yield* crypto.digest(Digest.sha256, new TextEncoder().encode(key)).pipe(Effect.orDie), ); return `${key.slice(0, 512 - digest.length - 1)}:${digest}`; }); @@ -133,7 +133,7 @@ export interface McpToolPresentation { export const mcpToolPresentation = Effect.fn("CodexToolPresentation.mcpToolPresentation")( function* ( item: Extract, - ): Effect.fn.Return { + ): Effect.fn.Return { const result = asUnknownRecord(item.result); const metadata = asUnknownRecord(result?._meta); const surface = asUnknownRecord(metadata?.["codex/toolSurface"]); diff --git a/apps/server/src/provider/Drivers/ClaudeAgentSdk.ts b/apps/server/src/provider/Drivers/ClaudeAgentSdk.ts index 7da3a03f414c2..c379c9f8d2dcc 100644 --- a/apps/server/src/provider/Drivers/ClaudeAgentSdk.ts +++ b/apps/server/src/provider/Drivers/ClaudeAgentSdk.ts @@ -53,6 +53,7 @@ const SUPPORTED_QUERY_OPTIONS: ReadonlySet = new Set([ "supportedDialogKinds", "systemPrompt", "thinking", + "title", "tools", ]); @@ -129,6 +130,7 @@ export function toClaudeCliOptions(options: Sdk.Options): Cli.Options { : {}), ...(options.strictMcpConfig ? { strictMcpConfig: true } : {}), ...(options.supportedDialogKinds ? { supportedDialogKinds: options.supportedDialogKinds } : {}), + ...(options.title !== undefined ? { title: options.title } : {}), ...(systemPrompt ? { systemPrompt: { @@ -187,7 +189,8 @@ function toSdkQuery(cli: Cli.Query): Sdk.Query { applyFlagSettings: (settings) => cli.applyFlagSettings(settings), updateSettings: reject("settings updates"), reinitialize: reject("reinitialize"), - mcpServerStatus: reject("MCP server status"), + mcpServerStatus: () => cli.mcpServerStatus().then((statuses) => [...statuses]), + readMcpResource: (serverName, uri) => cli.readMcpResource(serverName, uri), reconnectMcpServer: reject("MCP reconnects"), toggleMcpServer: reject("MCP toggles"), setMcpServers: (servers) => { @@ -209,7 +212,11 @@ function toSdkQuery(cli: Cli.Query): Sdk.Query { backgroundTasks: (toolUseId) => cli.backgroundTasks(toolUseId), streamInput: reject("stream input after start"), }; - return Object.assign(messages(), query) as Sdk.Query; + // The SDK's private `request`, which ClaudeAdapterV2 reaches with `Reflect.get` for the control + // requests SDK 0.3.316 has no method for; it resolves to `{ response }` as the SDK's does. + const request = (body: Readonly>) => + cli.rawControlRequest(body).then((response) => ({ response })); + return Object.assign(messages(), query, { request }) as Sdk.Query; } /** SDK-compatible `query`, run through the workspace `claude` executable. */ diff --git a/apps/server/src/provider/Drivers/ClaudeCli.test.ts b/apps/server/src/provider/Drivers/ClaudeCli.test.ts index 7ad89ed4cd859..aac7394fb082e 100644 --- a/apps/server/src/provider/Drivers/ClaudeCli.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeCli.test.ts @@ -191,6 +191,7 @@ for await (const line of lines) { message: { role: "user", content: "hello" }, }; await dialogHandled; + // @effect-diagnostics-next-line globalTimers:off - the CLI transport is plain async code. await new Promise((resolve) => setTimeout(resolve, 25)); })(), options: { @@ -556,6 +557,82 @@ createInterface({ input: process.stdin }).on("line", (line) => { } }, 5000); +it("serves MCP Apps' control requests with the Agent SDK 0.3.316 wire shapes", async () => { + const directory = await NodeFS.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-claude-mcp-apps-")); + const executable = NodePath.join(directory, "fake-claude"); + // Answers only the SDK's shapes: mcp_status, mcp_read_resource { serverName, uri }, the raw + // mcp_call and list_permission_rules requests, and an initialize that carries the title. + await NodeFS.writeFile( + executable, + `#!/usr/bin/env node +const { createInterface } = require("node:readline"); +createInterface({ input: process.stdin }).on("line", (line) => { + const value = JSON.parse(line); + if (value.type !== "control_request") return; + const request = value.request; + const keys = Object.keys(request).sort().join(","); + const response = + request.subtype === "initialize" && request.title === "Named thread" + ? {} + : request.subtype === "mcp_status" && keys === "subtype" + ? { mcpServers: [{ name: "docs", status: "connected", tools: [{ name: "show" }] }] } + : request.subtype === "mcp_read_resource" && keys === "serverName,subtype,uri" && + request.serverName === "docs" && request.uri === "ui://docs/app.html" + ? { contents: [{ uri: request.uri, text: "

app

" }] } + : request.subtype === "mcp_call" && request.tool === "mcp__docs__show" && + request.result_not_for_model === true + ? { content: [{ type: "text", text: "shown" }] } + : request.subtype === "list_permission_rules" && keys === "subtype" + ? { state: { rules: [] } } + : undefined; + process.stdout.write(JSON.stringify({ + type: "control_response", + response: response === undefined + ? { subtype: "error", request_id: value.request_id, error: JSON.stringify(request) } + : { subtype: "success", request_id: value.request_id, response }, + }) + "\\n"); +}); +`, + { mode: 0o700 }, + ); + let finishPrompt!: () => void; + const promptFinished = new Promise((resolve) => { + finishPrompt = resolve; + }); + const runtime = query({ + prompt: (async function* (): AsyncGenerator { + await promptFinished; + })(), + options: { pathToClaudeCodeExecutable: executable, env: process.env, title: "Named thread" }, + }); + try { + await runtime.initializationResult(); + assert.deepEqual(await runtime.mcpServerStatus(), [ + { name: "docs", status: "connected", tools: [{ name: "show" }] }, + ]); + assert.deepEqual(await runtime.readMcpResource("docs", "ui://docs/app.html"), { + contents: [{ uri: "ui://docs/app.html", text: "

app

" }], + }); + assert.deepEqual( + await runtime.rawControlRequest({ + subtype: "mcp_call", + tool: "mcp__docs__show", + arguments: {}, + result_not_for_model: true, + }), + { content: [{ type: "text", text: "shown" }] }, + ); + assert.deepEqual(await runtime.rawControlRequest({ subtype: "list_permission_rules" }), { + state: { rules: [] }, + }); + await rejects(runtime.rawControlRequest({ subtype: "set_model" })); + } finally { + finishPrompt(); + runtime.close(); + await NodeFS.rm(directory, { force: true, recursive: true }); + } +}, 5000); + it("applies flag settings with the Agent SDK's apply_flag_settings control request", async () => { const directory = await NodeFS.mkdtemp( NodePath.join(NodeOS.tmpdir(), "t3-claude-flag-settings-"), diff --git a/apps/server/src/provider/Drivers/ClaudeCli.ts b/apps/server/src/provider/Drivers/ClaudeCli.ts index 4559e23570bb8..a134ba1dd4b49 100644 --- a/apps/server/src/provider/Drivers/ClaudeCli.ts +++ b/apps/server/src/provider/Drivers/ClaudeCli.ts @@ -22,6 +22,16 @@ export type SlashCommand = Sdk.SlashCommand; export type ModelInfo = Sdk.ModelInfo; export type SDKControlInitializeResponse = Sdk.SDKControlInitializeResponse; export type SDKControlGetContextUsageResponse = Sdk.SDKControlGetContextUsageResponse; +export type McpServerStatus = Sdk.McpServerStatus; +export type SDKControlMcpReadResourceResponse = Sdk.SDKControlMcpReadResourceResponse; +/** + * Control requests the adapter sends without a `Query` method (#29803's MCP Apps): SDK 0.3.316 + * types `mcp_call` and `list_permission_rules` but sends them only through its private `request`. + */ +const RAW_CONTROL_REQUEST_SUBTYPES: ReadonlySet = new Set([ + "mcp_call", + "list_permission_rules", +]); export type McpSetServersResult = Sdk.McpSetServersResult; export type UserDialogRequest = Sdk.UserDialogRequest; export type UserDialogResult = Sdk.UserDialogResult; @@ -87,6 +97,8 @@ export type Options = { /** CLI flags from the workspace launch-args setting; `null` is a flag without a value. */ readonly extraArgs?: Readonly>; readonly supportedDialogKinds?: ReadonlyArray; + /** Sent in `initialize`, as the SDK does; a titled session skips Claude's own naming request. */ + readonly title?: string; readonly systemPrompt?: { readonly type: "preset"; readonly preset: "claude_code"; @@ -335,6 +347,12 @@ export interface Query extends AsyncIterable { setMcpServers( servers: Readonly>>>, ): Promise; + /** The session's MCP servers and their tools, as the SDK's `mcpServerStatus` does. */ + mcpServerStatus(): Promise>; + /** Reads one resource from a connected MCP server, as the SDK's `readMcpResource` does. */ + readMcpResource(serverName: string, uri: string): Promise; + /** Sends one of `RAW_CONTROL_REQUEST_SUBTYPES` and resolves to its response payload. */ + rawControlRequest(request: Readonly>): Promise; initializationResult(): Promise; close(): void; } @@ -515,6 +533,7 @@ class ClaudeCliQuery implements Query { ...(options.supportedDialogKinds ? { supportedDialogKinds: options.supportedDialogKinds } : {}), + ...(options.title !== undefined ? { title: options.title } : {}), }).then((response) => response as SDKControlInitializeResponse); this.initialization.catch(() => undefined); this.pumpPrompt(prompt); @@ -588,6 +607,27 @@ class ClaudeCliQuery implements Query { ); } + mcpServerStatus(): Promise> { + return this.request({ subtype: "mcp_status" }).then( + (response) => (response.mcpServers ?? []) as ReadonlyArray, + ); + } + + readMcpResource(serverName: string, uri: string): Promise { + return this.request({ subtype: "mcp_read_resource", serverName, uri }).then( + (response) => response as unknown as SDKControlMcpReadResourceResponse, + ); + } + + rawControlRequest(request: Readonly>): Promise { + if (typeof request.subtype !== "string" || !RAW_CONTROL_REQUEST_SUBTYPES.has(request.subtype)) { + return Promise.reject( + new Error(`Unsupported Claude control request: ${String(request.subtype)}`), + ); + } + return this.request({ ...request }); + } + getUsage( options?: { readonly skipBehaviors?: boolean }, timeoutMs?: number, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 803415a73e569..68042ef92d7d5 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -1,7 +1,7 @@ import { ClaudeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -39,13 +39,7 @@ import { type ProviderInstance, } from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; -import { - enrichProviderSnapshotWithVersionAdvisory, - makeCachedProviderMaintenanceResolution, - makePackageManagedProviderMaintenanceResolver, - normalizeCommandPath, - resolveProviderMaintenanceCapabilitiesEffect, -} from "@t3tools/provider-core/server/maintenanceResolver"; +import { enrichProviderSnapshotWithVersionAdvisory } from "@t3tools/provider-core/server/maintenanceResolver"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -57,6 +51,7 @@ import { mergeClaudeProcessEnvironment, } from "./ClaudeHome.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { makeClaudeMaintenanceResolution } from "./ClaudeMaintenance.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -68,29 +63,11 @@ const withoutUsage = (capabilities: ClaudeCapabilitiesProbe): ClaudeCapabilities return result; }; -function isClaudeNativeCommandPath(commandPath: string): boolean { - const normalized = normalizeCommandPath(commandPath); - return ( - normalized.endsWith("/.local/bin/claude") || - normalized.endsWith("/.local/bin/claude.exe") || - normalized.includes("/.local/share/claude/") - ); -} - -const UPDATE = makePackageManagedProviderMaintenanceResolver({ - provider: DRIVER_KIND, - npmPackageName: "@anthropic-ai/claude-code", - nativeUpdate: { - args: ["update"], - isCommandPath: isClaudeNativeCommandPath, - }, -}); - export type ClaudeDriverEnv = | ClaudeAdapterV2DriverEnv | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | SecureRandom.SecureRandom | FileSystem.FileSystem | HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions @@ -137,15 +114,14 @@ export const ClaudeDriver: ProviderDriver { }); it.effect( - "drops the launching Claude session and labels processes t3-code unless overridden", + "drops the launching Claude session and Electron's run-as-Node flag, and labels processes t3-code unless overridden", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -72,6 +72,7 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { CLAUDE_CODE_SESSION_ID: "host-session", CLAUDE_CODE_MESSAGING_SOCKET: "/tmp/host-claude.sock", CLAUDE_EFFORT: "high", + ELECTRON_RUN_AS_NODE: "1", KEEP: "1", }; const instance = [ diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 4f6587ce83d9d..828fb72aecb15 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -76,6 +76,9 @@ export const mergeClaudeProcessEnvironment = Effect.fn("mergeClaudeProcessEnviro CLAUDE_CODE_ENTRYPOINT: "t3-code", }; for (const name of CLAUDE_HOST_SESSION_VARIABLES) delete baseEnvironment[name]; + // The desktop runs the server as Node through Electron. Commands Claude runs inherit its env, so + // an Electron app they start (T3 Code itself, VS Code) would run as Node and exit. + delete baseEnvironment.ELECTRON_RUN_AS_NODE; const environment = yield* mergeProviderInstanceEnvironment(instanceEnvironment, baseEnvironment); const settingsEntrypoint = (yield* readClaudeSettingsEnv(config, undefined, environment)) .CLAUDE_CODE_ENTRYPOINT; diff --git a/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts b/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts new file mode 100644 index 0000000000000..7897fe9b75c5d --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts @@ -0,0 +1,406 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { + enrichProviderSnapshotWithVersionAdvisory, + invalidateLatestProviderVersion, +} from "@t3tools/provider-core/server/maintenanceResolver"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { makeClaudeMaintenanceResolution } from "./ClaudeMaintenance.ts"; + +const snapshot: ServerProvider = { + instanceId: ProviderInstanceId.make("claude"), + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + version: "2.1.314", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-31T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +const fixture = Effect.fn("ClaudeMaintenance.test.fixture")(function* ( + npm = false, + platform: NodeJS.Platform = "linux", + programFiles?: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const binaryPath = npm + ? path.join(directory, "lib/node_modules/@anthropic-ai/claude-code/cli.js") + : path.join(directory, ".local/bin", platform === "win32" ? "claude.exe" : "claude"); + yield* fs.makeDirectory(path.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(binaryPath, "#!/bin/sh\n"); + yield* fs.chmod(binaryPath, 0o755); + const configDirectory = path.join(directory, "config"); + yield* fs.makeDirectory(configDirectory); + const settingsPath = path.join(configDirectory, "settings.json"); + const managed = new Map(); + const managedDirectory = + platform === "win32" + ? `${programFiles ?? "C:/Program Files"}/ClaudeCode` + : platform === "darwin" + ? "/Library/Application Support/ClaudeCode" + : "/etc/claude-code"; + let installationReads = 0; + const testFs = FileSystem.makeNoop({ + ...fs, + realPath: (file) => { + if (file === binaryPath) installationReads++; + return fs.realPath(file); + }, + readFileString: (file, ...args) => + file.replaceAll("\\", "/").startsWith(`${managedDirectory}/`) + ? managed.has(file.replaceAll("\\", "/")) + ? Effect.succeed(managed.get(file.replaceAll("\\", "/"))!) + : fs.readFileString(path.join(directory, "missing")) + : fs.readFileString(file, ...args), + readDirectory: (file) => + file.replaceAll("\\", "/") === `${managedDirectory}/managed-settings.d` + ? Effect.succeed( + [...managed.keys()] + .filter((key) => key.includes("/managed-settings.d/")) + .map((key) => path.basename(key)), + ) + : fs.readDirectory(file), + }); + return { + directory, + fs, + managed, + managedDirectory, + settingsPath, + installationReads: () => installationReads, + makeResolution: makeClaudeMaintenanceResolution({ binaryPath, env: { PATH: "" } }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("version checks must not run a subprocess")), + ), + Effect.provideService(FileSystem.FileSystem, testFs), + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(HostProcess.Environment, { + PATH: "", + CLAUDE_CONFIG_DIR: configDirectory, + ...(programFiles ? { PROGRAMFILES: programFiles } : {}), + }), + ), + }; +}); + +it.layer(NodeServices.layer)("ClaudeMaintenance", (it) => { + it.effect.each([ + { + name: "default native channel", + settings: undefined, + channel: "latest", + body: "2.1.314\n", + status: 200, + expected: "2.1.314", + advisory: "current", + npm: false, + }, + { + name: "explicit latest channel", + settings: '{"autoUpdatesChannel":"latest"}', + channel: "latest", + body: "2.1.314", + status: 200, + expected: "2.1.314", + advisory: "current", + npm: false, + }, + { + name: "stable channel", + settings: '{"autoUpdatesChannel":"stable"}', + channel: "stable", + body: "2.1.308", + status: 200, + expected: "2.1.308", + advisory: "current", + npm: false, + }, + { + name: "new native release", + settings: undefined, + channel: "latest", + body: "2.1.315", + status: 200, + expected: "2.1.315", + advisory: "behind_latest", + npm: false, + }, + { + name: "unavailable feed", + settings: undefined, + channel: "latest", + body: "Unavailable", + status: 503, + expected: null, + advisory: "unknown", + npm: false, + }, + { + name: "malformed feed", + settings: undefined, + channel: "latest", + body: "error", + status: 200, + expected: null, + advisory: "unknown", + npm: false, + }, + { + name: "empty feed", + settings: undefined, + channel: "latest", + body: " ", + status: 200, + expected: null, + advisory: "unknown", + npm: false, + }, + { + name: "npm install", + settings: '{"autoUpdatesChannel":"stable"}', + channel: "latest", + body: "", + status: 200, + expected: "2.1.316", + advisory: "behind_latest", + npm: true, + }, + ])("advertises the installable version for $name", (row) => + Effect.gen(function* () { + const f = yield* fixture(row.npm); + if (row.settings) yield* f.fs.writeFileString(f.settingsPath, row.settings); + const requests: string[] = []; + const client = HttpClient.make((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url.includes("claude-code-releases/") + ? new Response(row.body, { status: row.status }) + : Response.json({ version: "2.1.316" }), + ), + ); + }); + const resolve = yield* f.makeResolution.pipe( + Effect.provideService(HttpClient.HttpClient, client), + ); + const maintenance = yield* resolve(); + const versions = yield* ProviderLatestVersions.make(); + const enrich = enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenance).pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, versions), + ); + expect((yield* enrich).versionAdvisory).toMatchObject({ + status: row.advisory, + latestVersion: row.expected, + }); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatch( + row.npm ? /registry\.npmjs\.org/ : new RegExp(`claude-code-releases/${row.channel}$`), + ); + yield* enrich; + expect(requests).toHaveLength(1); + }).pipe(Effect.scoped), + ); + + it.effect.each([ + { name: "Linux", platform: "linux", programFiles: undefined }, + { name: "macOS", platform: "darwin", programFiles: undefined }, + { name: "Windows default path", platform: "win32", programFiles: undefined }, + { name: "Windows custom path", platform: "win32", programFiles: "D:/Programs" }, + ] as const)( + "separates channel caches and honors $name managed overrides and feed invalidation", + (row) => + Effect.gen(function* () { + const f = yield* fixture(false, row.platform, row.programFiles); + const requests: string[] = []; + const client = HttpClient.make((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(request.url.endsWith("/stable") ? "2.1.308" : "2.1.314"), + ), + ); + }); + const versions = yield* ProviderLatestVersions.make(); + const resolve = yield* f.makeResolution.pipe( + Effect.provideService(HttpClient.HttpClient, client), + ); + const check = Effect.gen(function* () { + const capabilities = yield* resolve(); + return yield* enrichProviderSnapshotWithVersionAdvisory(snapshot, capabilities); + }).pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, versions), + ); + expect((yield* check).versionAdvisory?.latestVersion).toBe("2.1.314"); + yield* f.fs.writeFileString(f.settingsPath, '{"autoUpdatesChannel":"stable"}'); + expect((yield* check).versionAdvisory?.latestVersion).toBe("2.1.308"); + f.managed.set( + `${f.managedDirectory}/managed-settings.json`, + '{"autoUpdatesChannel":"latest"}', + ); + expect((yield* check).versionAdvisory?.latestVersion).toBe("2.1.314"); + f.managed.set( + `${f.managedDirectory}/managed-settings.d/20-channel.json`, + '{"autoUpdatesChannel":"stable"}', + ); + f.managed.set( + `${f.managedDirectory}/managed-settings.d/10-channel.json`, + '{"autoUpdatesChannel":"latest"}', + ); + expect((yield* check).versionAdvisory?.latestVersion).toBe("2.1.308"); + expect(requests).toHaveLength(2); + const capabilities = yield* resolve(); + yield* invalidateLatestProviderVersion(capabilities).pipe( + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, versions), + ); + yield* check; + expect(requests).toHaveLength(3); + }).pipe(Effect.scoped), + ); + + it.effect("automatic refresh rereads the native channel while preserving both caches", () => + Effect.gen(function* () { + const f = yield* fixture(); + const requests: string[] = []; + const client = HttpClient.make((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(request.url.endsWith("/stable") ? "2.1.308" : "2.1.315"), + ), + ); + }); + const resolveMaintenance = yield* f.makeResolution.pipe( + Effect.provideService(HttpClient.HttpClient, client), + ); + const versions = yield* ProviderLatestVersions.make(); + const publications = yield* Queue.unbounded(); + yield* makeManagedServerProvider({ + resolveMaintenance, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(snapshot), + checkProvider: Effect.succeed(snapshot), + refreshInterval: "1 minute", + enrichSnapshot: ({ snapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenance) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenance), + ), + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, versions), + Effect.flatMap((enriched) => + publishSnapshot(enriched).pipe( + Effect.andThen(Queue.offer(publications, enriched)), + Effect.asVoid, + ), + ), + ), + }).pipe( + Effect.provide( + Layer.mock(ProviderHost.ProviderHost)({ + paths: { + cwd: f.directory, + baseDir: f.directory, + stateDir: f.directory, + providerStatusCacheDir: f.directory, + attachmentsDir: f.directory, + }, + settings: { + get: Effect.succeed(DEFAULT_SERVER_SETTINGS), + withSnapshot: (use) => use(DEFAULT_SERVER_SETTINGS), + changes: Stream.empty, + subscribe: Effect.succeed(Stream.empty), + }, + shouldRunBackgroundWork: () => Effect.succeed(true), + resolveAttachmentPath: () => null, + }), + ), + ); + expect((yield* Queue.take(publications)).versionAdvisory).toMatchObject({ + latestVersion: "2.1.315", + status: "behind_latest", + }); + expect(requests).toHaveLength(1); + + for (const channel of ["stable", "stable", "latest"] as const) { + yield* f.fs.writeFileString( + f.settingsPath, + JSON.stringify({ autoUpdatesChannel: channel }), + ); + yield* TestClock.adjust("1 minute"); + expect((yield* Queue.take(publications)).versionAdvisory).toMatchObject({ + latestVersion: channel === "stable" ? "2.1.308" : "2.1.315", + status: channel === "stable" ? "current" : "behind_latest", + }); + expect(requests).toEqual([ + "https://downloads.claude.ai/claude-code-releases/latest", + "https://downloads.claude.ai/claude-code-releases/stable", + ]); + expect(f.installationReads()).toBe(1); + } + }).pipe(Effect.scoped), + ); + + it.effect("bounds stalled feed bodies and skips disabled update checks", () => + Effect.gen(function* () { + const f = yield* fixture(); + let requests = 0; + const client = HttpClient.make((request) => { + requests++; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(new ReadableStream())), + ); + }); + const resolve = yield* f.makeResolution.pipe( + Effect.provideService(HttpClient.HttpClient, client), + ); + const capabilities = yield* resolve(); + const enrich = (enabled: boolean) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, capabilities, { + enableProviderUpdateChecks: enabled, + }).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService(HttpClient.HttpClient, client), + ); + expect((yield* enrich(false)).versionAdvisory?.status).toBe("unknown"); + expect(requests).toBe(0); + const pending = yield* enrich(true).pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("4 seconds"); + expect((yield* Fiber.join(pending)).versionAdvisory?.status).toBe("unknown"); + expect(requests).toBe(1); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeMaintenance.ts b/apps/server/src/provider/Drivers/ClaudeMaintenance.ts new file mode 100644 index 0000000000000..d9975762647d4 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeMaintenance.ts @@ -0,0 +1,116 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { + makeCachedProviderMaintenanceResolution, + makePackageManagedProviderMaintenanceResolver, + normalizeCommandPath, + resolveProviderMaintenanceCapabilitiesEffect, + type ProviderMaintenanceCapabilities, + type ProviderMaintenanceResolverServices, +} from "@t3tools/provider-core/server/maintenanceResolver"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { resolveClaudeHomePath } from "./ClaudeHome.ts"; + +const NATIVE_RELEASES = "https://downloads.claude.ai/claude-code-releases"; +const UpdateChannelSettings = fromLenientJson( + Schema.Struct({ autoUpdatesChannel: Schema.optional(Schema.Literals(["latest", "stable"])) }), +); +const NativeVersion = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+$/)); + +const update = makePackageManagedProviderMaintenanceResolver({ + provider: ProviderDriverKind.make("claudeAgent"), + npmPackageName: "@anthropic-ai/claude-code", + nativeUpdate: { + args: ["update"], + isCommandPath: (commandPath) => { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.endsWith("/.local/bin/claude") || + normalized.endsWith("/.local/bin/claude.exe") || + normalized.includes("/.local/share/claude/") + ); + }, + }, +}); + +const readUpdateChannel = Effect.fn("ClaudeMaintenance.readUpdateChannel")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* HostProcess.Environment; + const platform = yield* HostProcess.Platform; + const managedDirectory = + platform === "darwin" + ? "/Library/Application Support/ClaudeCode" + : platform === "win32" + ? path.join(environment.PROGRAMFILES || "C:\\Program Files", "ClaudeCode") + : "/etc/claude-code"; + const dropInDirectory = path.join(managedDirectory, "managed-settings.d"); + const dropIns = yield* fs.readDirectory(dropInDirectory).pipe(Effect.orElseSucceed(() => [])); + // The updater inherits the server's config directory, not the instance's homePath. + const configDirectory = yield* resolveClaudeHomePath({ homePath: "" }, environment); + const settingsPaths = [ + path.join(configDirectory, "settings.json"), + path.join(managedDirectory, "managed-settings.json"), + ...dropIns + .filter((name) => name.endsWith(".json") && !name.startsWith(".")) + .toSorted() + .map((name) => path.join(dropInDirectory, name)), + ]; + const channels = yield* Effect.forEach(settingsPaths, (settingsPath) => + fs.readFileString(settingsPath).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(UpdateChannelSettings)), + Effect.map((settings) => settings.autoUpdatesChannel), + Effect.orElseSucceed(() => undefined), + ), + ); + return channels.findLast((channel) => channel !== undefined) ?? "latest"; +}); + +const resolveClaudeMaintenance = Effect.fn("ClaudeMaintenance.resolve")(function* ( + capabilities: ProviderMaintenanceCapabilities, +) { + if (capabilities.update?.lockKey !== "claudeAgent-native") return capabilities; + const channel = yield* readUpdateChannel(); + const client = yield* HttpClient.HttpClient; + const url = `${NATIVE_RELEASES}/${channel}`; + return { + ...capabilities, + latestVersion: null, + latestVersionCacheKey: url, + lookupLatestVersion: client.get(url).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.text), + Effect.map((text) => text.trim()), + Effect.flatMap(Schema.decodeUnknownEffect(NativeVersion)), + Effect.timeoutOption("4 seconds"), + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ), + }; +}); + +export const makeClaudeMaintenanceResolution = Effect.fn("ClaudeMaintenance.make")(function* ( + options: Parameters[1], +) { + const services = yield* Effect.context< + ProviderMaintenanceResolverServices | HttpClient.HttpClient + >(); + const resolveInstallation = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(update, options).pipe( + Effect.provideContext(services), + ), + ); + const resolve: typeof resolveInstallation = (refreshOptions) => + resolveInstallation(refreshOptions).pipe( + Effect.flatMap(resolveClaudeMaintenance), + Effect.provideContext(services), + ); + return resolve; +}); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 860280aa91e89..b4ce31cac866c 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -209,6 +209,71 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("resolves each install record's project repository once per discovery", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Real path: macOS temp dirs sit behind a /var -> /private/var symlink. + const tempDir = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }), + ); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + const otherProject = path.join(tempDir, "other", "packages", "app"); + const sameRepoProject = path.join(workspace, "packages", "app"); + yield* fs.makeDirectory(path.join(workspace, ".git"), { recursive: true }); + yield* fs.makeDirectory(path.join(tempDir, "other", ".git"), { recursive: true }); + yield* fs.makeDirectory(otherProject, { recursive: true }); + yield* fs.makeDirectory(sameRepoProject, { recursive: true }); + const elsewhereInstall = path.join(configDir, "plugins", "elsewhere"); + const sameRepoInstall = path.join(configDir, "plugins", "same-repo"); + yield* writeSkill(path.join(elsewhereInstall, "skills"), "elsewhere", "# Elsewhere"); + yield* writeSkill(path.join(sameRepoInstall, "skills"), "same-repo", "# Same repo"); + const records = Array.from({ length: 4 }, () => ({ + scope: "project", + projectPath: otherProject, + installPath: elsewhereInstall, + })); + const plugins = ["a@shop", "b@shop", "c@shop"]; + yield* fs.writeFileString( + path.join(configDir, "plugins", "installed_plugins.json"), + JSON.stringify({ + plugins: { + ...Object.fromEntries(plugins.map((key) => [key, records])), + "d@shop": [ + { scope: "project", projectPath: sameRepoProject, installPath: sameRepoInstall }, + ], + }, + }), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + JSON.stringify({ + enabledPlugins: Object.fromEntries([...plugins, "d@shop"].map((key) => [key, true])), + }), + ); + + const probed: Array = []; + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + exists: (target) => { + probed.push(target); + return fs.exists(target); + }, + }), + ); + assert.strictEqual( + probed.filter((target) => target === path.join(otherProject, ".git")).length, + 1, + ); + assert.deepEqual( + skills.map((skill) => skill.name), + ["d:same-repo"], + ); + }), + ); + it.effect("publishes and dispatches canonical plugin folder names regardless of aliases", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 032725b96e33b..d8bef60c972a2 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -144,7 +144,8 @@ export function claudeSettingsPaths( ]; } -const findRepositoryRoot = Effect.fn("findRepositoryRoot")(function* ( +// Untraced: skill discovery resolves every distinct plugin project path, too often for spans. +const findRepositoryRoot = Effect.fnUntraced(function* ( cwd: string, ): Effect.fn.Return { const fileSystem = yield* FileSystem.FileSystem; @@ -165,7 +166,7 @@ const findRepositoryRoot = Effect.fn("findRepositoryRoot")(function* ( } }); -const findPluginRepositoryRoot = Effect.fn("findPluginRepositoryRoot")(function* (cwd: string) { +const findPluginRepositoryRoot = Effect.fnUntraced(function* (cwd: string) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* findRepositoryRoot(cwd); @@ -337,6 +338,8 @@ const discoverPluginSkillRoots = Effect.fn("discoverPluginSkillRoots")(function* cwd === undefined ? undefined : yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => path.resolve(cwd))); + // Install records repeat project paths, often hundreds of times per discovery. + const projectRepositoryRoots = new Map(); const repositoryRoot = workspace === undefined ? undefined : yield* findPluginRepositoryRoot(workspace); @@ -348,11 +351,13 @@ const discoverPluginSkillRoots = Effect.fn("discoverPluginSkillRoots")(function* Effect.gen(function* () { if (record.scope === "user" || record.scope === "managed") return true; if (record.projectPath === undefined || workspace === undefined) return false; - if (path.resolve(record.projectPath) === workspace) return true; - return ( - repositoryRoot !== undefined && - (yield* findPluginRepositoryRoot(record.projectPath)) === repositoryRoot - ); + const projectPath = path.resolve(record.projectPath); + if (projectPath === workspace) return true; + if (repositoryRoot === undefined) return false; + if (!projectRepositoryRoots.has(projectPath)) { + projectRepositoryRoots.set(projectPath, yield* findPluginRepositoryRoot(projectPath)); + } + return projectRepositoryRoots.get(projectPath) === repositoryRoot; }), ); // The native loader keeps registry order, preferring the first install that exists. @@ -432,6 +437,9 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct const SettingsEnv = Schema.Struct({ ANTHROPIC_BASE_URL: Schema.optional(Schema.String), CLAUDE_CODE_ENTRYPOINT: Schema.optional(Schema.String), + // Unquoted JSON values must not fail the decode and drop ANTHROPIC_BASE_URL with them. + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: Schema.optional(Schema.Unknown), + CLAUDE_CODE_GATEWAY_MODEL_DISCOVERY_TIMEOUT_MS: Schema.optional(Schema.Unknown), }); const decodeSettingsEnv = Schema.decodeUnknownEffect( fromLenientJson(Schema.Struct({ env: Schema.optional(SettingsEnv) })), diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index dfba5551f5c92..c5114253d9040 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -1,5 +1,6 @@ import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; // @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodePath from "node:path"; import { expect, it } from "@effect/vitest"; @@ -33,7 +34,7 @@ import * as ProviderHostLive from "../ProviderHostLive.ts"; const layerDeps = ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-driver-maintenance-", }).pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(IdAllocator.layer), Layer.provideMerge(McpProviderSessions.layer), Layer.provideMerge( @@ -391,7 +392,7 @@ it.layer(layerTest)("CodexDriver", (it) => { if (!ChildProcess.isStandardCommand(command) || command.command !== brewPath) { return Effect.die("Provider resolution must not execute a provider or updater"); } - probes.push(command.args); + probes.push(command.args.map(String)); const stdout = command.args[0] === "--prefix" ? brewPrefix diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index fe324ff2503ff..36376a65c08d3 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -1,5 +1,7 @@ import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import type * as Signature from "effect/crypto/Signature"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -88,7 +90,9 @@ export type CodexDriverEnv = | CodexAdapterV2DriverEnv | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | Digest.Digest + | SecureRandom.SecureRandom + | Signature.Signature | FileSystem.FileSystem | HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions diff --git a/apps/server/src/provider/PastedImageAttachments.test.ts b/apps/server/src/provider/PastedImageAttachments.test.ts index 3612f6c10fe32..374f361b74ca6 100644 --- a/apps/server/src/provider/PastedImageAttachments.test.ts +++ b/apps/server/src/provider/PastedImageAttachments.test.ts @@ -109,10 +109,7 @@ it.layer(NodeServices.layer)("resolvePastedImageAttachment", (it) => { const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-image-" }); const outside = NodePath.join(attachmentsDir, "outside.png"); yield* fileSystem.writeFile(outside, PNG_BYTES); - yield* Effect.tryPromise({ - try: () => NodeFS.symlink(outside, NodePath.join(attachmentsDir, IMAGE_FILE)), - catch: (cause) => cause, - }); + yield* fileSystem.symlink(outside, NodePath.join(attachmentsDir, IMAGE_FILE)); const symlinkError = yield* Effect.flip( resolvePastedImageAttachment({ @@ -122,10 +119,7 @@ it.layer(NodeServices.layer)("resolvePastedImageAttachment", (it) => { ); assert.instanceOf(symlinkError, PastedImageAttachmentError); - yield* Effect.tryPromise({ - try: () => NodeFS.rm(NodePath.join(attachmentsDir, IMAGE_FILE)), - catch: (cause) => cause, - }); + yield* fileSystem.remove(NodePath.join(attachmentsDir, IMAGE_FILE)); yield* fileSystem.writeFileString(NodePath.join(attachmentsDir, IMAGE_FILE), "not an image"); const signatureError = yield* Effect.flip( resolvePastedImageAttachment({ diff --git a/apps/server/src/provider/PastedImageAttachments.ts b/apps/server/src/provider/PastedImageAttachments.ts index 2a9953991e02b..f98759ffb3062 100644 --- a/apps/server/src/provider/PastedImageAttachments.ts +++ b/apps/server/src/provider/PastedImageAttachments.ts @@ -11,6 +11,7 @@ import { type ChatAttachment, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import { readAttachmentBytes } from "../readAttachmentBytes.ts"; @@ -24,12 +25,10 @@ export interface ResolvedPastedImageAttachment { readonly sizeBytes: number; } -export class PastedImageAttachmentError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = "PastedImageAttachmentError"; - } -} +export class PastedImageAttachmentError extends Schema.TaggedError()( + "PastedImageAttachmentError", + { message: Schema.String }, +) {} export const resolvePastedImageAttachment = Effect.fn("resolvePastedImageAttachment")( function* (input: { readonly attachmentsDir: string; readonly attachment: ChatAttachment }) { @@ -38,18 +37,18 @@ export const resolvePastedImageAttachment = Effect.fn("resolvePastedImageAttachm input.attachment.type !== "image" || !isProviderSendTurnSupportedImageMimeType(expectedMimeType) ) { - return yield* Effect.fail( - new PastedImageAttachmentError("Pasted image attachment type is unsupported."), - ); + return yield* new PastedImageAttachmentError({ + message: "Pasted image attachment type is unsupported.", + }); } const path = resolveAttachmentPath({ attachmentsDir: input.attachmentsDir, attachment: input.attachment, }); if (!path || NodePath.dirname(path) !== NodePath.resolve(input.attachmentsDir)) { - return yield* Effect.fail( - new PastedImageAttachmentError("Pasted image attachment id is invalid."), - ); + return yield* new PastedImageAttachmentError({ + message: "Pasted image attachment id is invalid.", + }); } const resolved = yield* Effect.tryPromise({ @@ -61,12 +60,16 @@ export const resolvePastedImageAttachment = Effect.fn("resolvePastedImageAttachm try { const stat = await handle.stat(); if (!stat.isFile() || stat.size === 0 || stat.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { - throw new Error("Pasted image attachment has an invalid size or file type."); + throw new PastedImageAttachmentError({ + message: "Pasted image attachment has an invalid size or file type.", + }); } const bytes = await readAttachmentBytes(handle, stat.size); const mimeType = detectImageMimeType(bytes); if (mimeType !== expectedMimeType) { - throw new Error("Pasted image attachment content does not match its declared type."); + throw new PastedImageAttachmentError({ + message: "Pasted image attachment content does not match its declared type.", + }); } return { path, @@ -79,7 +82,9 @@ export const resolvePastedImageAttachment = Effect.fn("resolvePastedImageAttachm } }, catch: () => - new PastedImageAttachmentError("Pasted image attachment could not be read safely."), + new PastedImageAttachmentError({ + message: "Pasted image attachment could not be read safely.", + }), }); return resolved; @@ -107,7 +112,7 @@ export const resolvePastedImageAttachments = Effect.fn("resolvePastedImageAttach resolved.push(image); // Legacy attachments carry no size, so bound the bytes actually read. const limitError = getProviderAttachmentLimitError(resolved); - if (limitError) return yield* Effect.fail(new PastedImageAttachmentError(limitError)); + if (limitError) return yield* new PastedImageAttachmentError({ message: limitError }); } return resolved; }, diff --git a/apps/server/src/provider/ProviderAuthService.ts b/apps/server/src/provider/ProviderAuthService.ts index ab5f72d519759..4c0d2c8ad8261 100644 --- a/apps/server/src/provider/ProviderAuthService.ts +++ b/apps/server/src/provider/ProviderAuthService.ts @@ -73,14 +73,30 @@ const unavailable = (instanceId: ProviderInstanceId, operation: string) => export const layer = Layer.succeed( ProviderAuthService, ProviderAuthService.of({ - reconnectProfile: (input) => Effect.fail(unavailable(input.instanceId, "reconnectProfile")), - importProfile: (input) => Effect.fail(unavailable(input.instanceId, "importProfile")), - start: (input) => Effect.fail(unavailable(input.instanceId, "start")), - complete: (input) => Effect.fail(unavailable(input.instanceId, "complete")), - respond: (input) => Effect.fail(unavailable(input.instanceId, "respond")), - cancel: (input) => Effect.fail(unavailable(input.instanceId, "cancel")), - logout: (input) => Effect.fail(unavailable(input.instanceId, "logout")), + reconnectProfile: Effect.fn("ProviderAuthService.reconnectProfile")(function* (input) { + return yield* unavailable(input.instanceId, "reconnectProfile"); + }), + importProfile: Effect.fn("ProviderAuthService.importProfile")(function* (input) { + return yield* unavailable(input.instanceId, "importProfile"); + }), + start: Effect.fn("ProviderAuthService.start")(function* (input) { + return yield* unavailable(input.instanceId, "start"); + }), + complete: Effect.fn("ProviderAuthService.complete")(function* (input) { + return yield* unavailable(input.instanceId, "complete"); + }), + respond: Effect.fn("ProviderAuthService.respond")(function* (input) { + return yield* unavailable(input.instanceId, "respond"); + }), + cancel: Effect.fn("ProviderAuthService.cancel")(function* (input) { + return yield* unavailable(input.instanceId, "cancel"); + }), + logout: Effect.fn("ProviderAuthService.logout")(function* (input) { + return yield* unavailable(input.instanceId, "logout"); + }), subscribe: (input) => Stream.fail(unavailable(input.instanceId, "subscribe")), - tryHandlePromptCommand: () => Effect.succeed(false), + tryHandlePromptCommand: Effect.fn("ProviderAuthService.tryHandlePromptCommand")(function* () { + return false; + }), }), ); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 288b23954b776..a7a9d252e5294 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -1,6 +1,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { type ClaudeSettings, @@ -142,7 +143,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { const layerBaseDeps = ServerConfig.layerTest(process.cwd(), { prefix: "provider-instance-registry-test", }).pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), Layer.provideMerge(Layer.mock(SecretResolver.SecretResolver)({})), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index 91c9d86beca87..80fe786a01482 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -9,6 +9,8 @@ import { type SecretReference, secretReferencesInSettings, type ServerProvider, + type SettingsSecretSlot, + withSettingsSecrets, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -46,7 +48,7 @@ export class ProviderInstanceRegistry extends Context.Service< interface InstanceSecrets { readonly environment: ResolvedProviderInstanceEnvironment; - readonly config: Readonly>; + readonly config: ReadonlyArray; } interface LiveEntry { @@ -111,12 +113,12 @@ const readInstanceSecrets = Effect.fn("ProviderInstanceRegistry.readInstanceSecr } return [{ ...variable, value: secret, sensitive: true }]; }); - const config: Record = {}; - for (const { field } of configFields) { + const config = configFields.flatMap(({ field, key }) => { const secret = values[index++]; - if (secret === undefined) unreadable.push(field); - else config[field] = secret; - } + if (secret !== undefined) return [{ field, key, value: secret }]; + unreadable.push(field); + return []; + }); const secrets: InstanceSecrets = { environment, config }; return { secrets, unreadable }; }); @@ -209,7 +211,7 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* ; + readonly models?: ReadonlyArray; }; function claudeCapabilities(overrides: Partial = {}) { @@ -306,6 +310,7 @@ const awaitPersistedProvider = ( const layerTestNodeServices = Layer.mergeAll( ProviderLatestVersions.layer, NodeServices.layer, + NodeCrypto.layer, Layer.mock(ServerSecretStore.ServerSecretStore)({}), // An empty home, so the developer's own Claude settings never reach provider probes. Layer.effect( @@ -1079,6 +1084,7 @@ it.layer( }); yield* writeProviderStatusCache({ filePath, provider: cachedProvider }); const nextProvider = yield* Ref.make(refreshedProvider); + const currentProvider = yield* Ref.make(pendingProvider); const instance = { instanceId: cachedProvider.instanceId, driverKind: cachedProvider.driver, @@ -1096,8 +1102,10 @@ it.layer( packageName: null, }), ), - getSnapshot: Effect.succeed(pendingProvider), - refresh: Ref.get(nextProvider), + getSnapshot: Ref.get(currentProvider), + refresh: Ref.get(nextProvider).pipe( + Effect.tap((provider) => Ref.set(currentProvider, provider)), + ), streamChanges: Stream.empty, applyUsageLimits: () => Effect.void, }, @@ -1118,6 +1126,7 @@ it.layer( const retainedModels = [defaultModel, customModel, currentModel]; for (const restarted of [false, true]) { + yield* Ref.set(currentProvider, pendingProvider); yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; assert.deepStrictEqual( @@ -1154,7 +1163,7 @@ it.layer( Effect.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-pi-model-cache-", - }).pipe(Layer.provideMerge(NodeServices.layer)), + }).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ), ), ); @@ -1214,6 +1223,7 @@ it.layer( }); yield* writeProviderStatusCache({ filePath, provider: cachedProvider }); const nextProvider = yield* Ref.make(refreshedProvider); + const currentProvider = yield* Ref.make(pendingProvider); const instance = { instanceId: cachedProvider.instanceId, driverKind: cachedProvider.driver, @@ -1231,8 +1241,10 @@ it.layer( packageName: null, }), ), - getSnapshot: Effect.succeed(pendingProvider), - refresh: Ref.get(nextProvider), + getSnapshot: Ref.get(currentProvider), + refresh: Ref.get(nextProvider).pipe( + Effect.tap((provider) => Ref.set(currentProvider, provider)), + ), streamChanges: Stream.empty, applyUsageLimits: () => Effect.void, }, @@ -1256,6 +1268,7 @@ it.layer( ]; for (const restarted of [false, true]) { + yield* Ref.set(currentProvider, pendingProvider); yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; const expectedModels = restarted @@ -1290,7 +1303,7 @@ it.layer( Effect.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-retired-model-cache-", - }).pipe(Layer.provideMerge(NodeServices.layer)), + }).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ), ), ); @@ -1489,6 +1502,143 @@ it.layer( ); }); + it.effect("invalidates model caches only for an explicit model refresh", () => + Effect.gen(function* () { + const forceRefreshCalls = yield* Ref.make(0); + const makeInstance = (name: string) => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make(name); + const provider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const invalidations = yield* Ref.make(0); + const maintenanceReads = yield* Ref.make>([]); + const instance = { + instanceId, + driverKind: driver, + continuationIdentity: { + driverKind: driver, + continuationKey: `codex:instance:${name}`, + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: (options) => + Ref.update(maintenanceReads, (reads) => [...reads, options?.fresh === true]).pipe( + Effect.as( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: `${name}-package`, + }), + ), + ), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + invalidateCaches: Ref.update(invalidations, (count) => count + 1), + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const takeMaintenanceReads = Ref.getAndSet(maintenanceReads, []); + return { instance, invalidations, takeMaintenanceReads }; + }); + const first = yield* makeInstance("codex_first"); + const second = yield* makeInstance("codex_second"); + const instances = [first.instance, second.instance]; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)), + listInstances: Effect.succeed(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const layerModelManifest = Layer.succeed(ModelManifest.ModelManifest, { + current: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), + refresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), + forceRefresh: Ref.update(forceRefreshCalls, (count) => count + 1).pipe( + Effect.as(ModelManifest.BUNDLED_MODEL_MANIFEST), + ), + refreshInBackground: Effect.void, + }); + const layerLatestVersions = Layer.effect( + ProviderLatestVersions.ProviderLatestVersions, + ProviderLatestVersions.make([ + ["codex_first-package", "1.0.0"], + ["codex_second-package", "1.0.0"], + ]), + ); + const readCachedVersion = (key: string) => + ProviderLatestVersions.ProviderLatestVersions.use((latestVersions) => + latestVersions.cached(key, Effect.succeed("2.0.0")), + ); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + + yield* registry.refreshInstance(first.instance.instanceId); + yield* registry.refresh(); + assert.strictEqual(yield* Ref.get(forceRefreshCalls), 0); + assert.strictEqual(yield* Ref.get(first.invalidations), 0); + assert.strictEqual(yield* readCachedVersion("codex_first-package"), "1.0.0"); + yield* first.takeMaintenanceReads; + + yield* registry.invalidateModelCaches({ instanceId: first.instance.instanceId }); + // The instance refresh that follows resolves installer metadata fresh itself. + assert.deepStrictEqual(yield* first.takeMaintenanceReads, [false]); + assert.strictEqual(yield* Ref.get(forceRefreshCalls), 1); + assert.strictEqual(yield* Ref.get(first.invalidations), 1); + assert.strictEqual(yield* Ref.get(second.invalidations), 0); + assert.strictEqual(yield* readCachedVersion("codex_first-package"), "2.0.0"); + assert.strictEqual(yield* readCachedVersion("codex_second-package"), "1.0.0"); + + yield* registry.invalidateModelCaches({}); + assert.strictEqual(yield* Ref.get(first.invalidations), 2); + assert.strictEqual(yield* Ref.get(second.invalidations), 1); + assert.strictEqual(yield* readCachedVersion("codex_second-package"), "2.0.0"); + yield* first.takeMaintenanceReads; + + // Only a cached workspace refresh leaves the fresh resolution to this call. + const workspace = { instanceId: first.instance.instanceId, cwd: process.cwd() }; + yield* registry.invalidateModelCaches(workspace); + assert.deepStrictEqual(yield* first.takeMaintenanceReads, [true]); + yield* registry.invalidateModelCaches({ ...workspace, fresh: true }); + assert.deepStrictEqual(yield* first.takeMaintenanceReads, [false]); + }).pipe( + Effect.provide( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge(layerModelManifest), + Layer.provideMerge(layerLatestVersions), + Layer.provide(layerUnusedMutator), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-model-cache-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }), + ); + it.effect( "a second request for a cwd being scanned gets its result, even if the first leaves", () => @@ -1561,7 +1711,7 @@ it.layer( prefix: "t3-provider-registry-shared-workspace-scan-", }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -1657,7 +1807,7 @@ it.layer( prefix: "t3-provider-registry-unhealthy-workspace-", }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -1778,7 +1928,7 @@ it.layer( prefix: "t3-provider-registry-shared-refresh-", }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -1800,6 +1950,266 @@ it.layer( }), ); + it.effect("ignores a probe from an instance that settings replaced mid-refresh", () => + Effect.gen(function* () { + const scenario = Effect.fn(function* ( + probeFails: boolean, + run: ( + registry: ProviderRegistry.ProviderRegistry["Service"], + ) => Effect.Effect>, + ) { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const provider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "2.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const retiredProbe = { + ...provider, + status: "error", + installed: false, + version: "1.0.0", + message: "Codex CLI is not installed.", + } as const satisfies ServerProvider; + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); + const makeInstance = (snapshot: ServerProvider, refresh: Effect.Effect) => + ({ + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(snapshot), + refresh: Effect.as(refresh, snapshot), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }) satisfies ProviderInstance; + const retiredInstance = makeInstance( + retiredProbe, + Deferred.succeed(probeStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseProbe)), + Effect.andThen(probeFails ? Effect.die(new Error("probe failed")) : Effect.void), + ), + ); + const rebuiltInstance = makeInstance(provider, Effect.void); + const instances = yield* Ref.make>([retiredInstance]); + const changes = yield* PubSub.unbounded(); + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Ref.get(instances).pipe( + Effect.map((current) => + current.find((instance) => instance.instanceId === requestedId), + ), + ), + listInstances: Ref.get(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: PubSub.subscribe(changes), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-retired-refresh-", + }), + ), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ).pipe(Scope.provide(scope)); + + return yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const rebuiltPublished = yield* Stream.toPull( + registry.streamChanges.pipe( + Stream.filter((providers) => providers[0]?.version === provider.version), + ), + ); + const refresh = yield* run(registry).pipe(Effect.forkChild); + yield* Deferred.await(probeStarted); + yield* Ref.set(instances, [rebuiltInstance]); + yield* PubSub.publish(changes, undefined); + yield* rebuiltPublished; + yield* Deferred.succeed(releaseProbe, undefined); + return { + provider, + result: yield* Fiber.join(refresh), + providers: yield* registry.getProviders, + }; + }).pipe(Effect.provide(runtimeServices)); + }); + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + + const manual = yield* scenario(false, (registry) => registry.refresh()); + expect(manual.result).toEqual([expect.objectContaining(manual.provider)]); + expect(manual.providers).toEqual([expect.objectContaining(manual.provider)]); + + // Neither the retired probe's failure nor its cached row can stand in for the replacement. + for (const probeFails of [true, false]) { + const afterUpdate = yield* scenario(probeFails, (registry) => + registry.refreshAfterUpdate(driver, instanceId), + ); + expect(afterUpdate.result).toEqual([]); + expect(afterUpdate.providers).toEqual([expect.objectContaining(afterUpdate.provider)]); + } + }), + ); + + it.effect("keeps an enriched snapshot that streams in before a manual refresh returns", () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("acpRegistry"); + const instanceId = ProviderInstanceId.make("gemini"); + const initialProvider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const pendingProvider = { + ...initialProvider, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-06-10T00:01:00.000Z", + message: "Checking ACP authentication, models, and commands in the background...", + } as const satisfies ServerProvider; + const enrichedProvider = { + ...initialProvider, + status: "error", + auth: { status: "unauthenticated" }, + checkedAt: "2026-06-10T00:01:01.000Z", + message: "Sign in to Gemini to use this agent.", + } as const satisfies ServerProvider; + const snapshotRef = yield* Ref.make(initialProvider); + const changes = yield* PubSub.unbounded(); + const publishSnapshot = (provider: ServerProvider) => + Ref.set(snapshotRef, provider).pipe(Effect.andThen(PubSub.publish(changes, provider))); + const releaseRefresh = yield* Deferred.make(); + const instance = { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "acpRegistry:gemini" }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + getSnapshot: Ref.get(snapshotRef), + // Like a managed provider: publish the probe, let enrichment publish its result, + // then return the probe after a slow tail such as ACP's refreshMethods. + refresh: Effect.gen(function* () { + yield* publishSnapshot(pendingProvider); + yield* publishSnapshot(enrichedProvider); + yield* Deferred.await(releaseRefresh); + return pendingProvider; + }), + streamChanges: Stream.fromPubSub(changes), + applyUsageLimits: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Effect.succeed(requestedId === instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-enriched-refresh-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const config = yield* ServerConfig.ServerConfig; + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId, + }); + expect(yield* registry.getProviders).toEqual([expect.objectContaining(initialProvider)]); + + const enrichedObserved = yield* awaitPersistedProvider( + registry, + enrichedProvider.checkedAt, + ); + const refreshed = yield* registry.refreshInstance(instanceId).pipe(Effect.forkChild); + yield* Fiber.join(enrichedObserved); + yield* Deferred.succeed(releaseRefresh, undefined); + const returned = yield* Fiber.join(refreshed); + + const enriched = { + status: enrichedProvider.status, + auth: enrichedProvider.auth, + message: enrichedProvider.message, + checkedAt: enrichedProvider.checkedAt, + }; + expect( + yield* registry.getProviders, + "getProviders should hold the enriched snapshot, not the stale pending one refresh returned", + ).toEqual([expect.objectContaining(enriched)]); + expect(returned).toEqual([expect.objectContaining(enriched)]); + expect(yield* readProviderStatusCache(filePath)).toEqual( + expect.objectContaining(enriched), + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("persists the merged snapshot when a live update has empty models", () => Effect.gen(function* () { const cursorDriver = ProviderDriverKind.make("cursor"); @@ -1886,7 +2296,7 @@ it.layer( }), ), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -2175,7 +2585,7 @@ it.layer( prefix: "t3-provider-registry-opencode-authoritative-persist-", }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -2280,7 +2690,7 @@ it.layer( }), ), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -2368,7 +2778,7 @@ it.layer( return beforeSpawn.pipe(Effect.andThen(spawner.spawn(command))); }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), ); const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( @@ -2468,7 +2878,7 @@ it.layer( Layer.provideMerge(ProviderLatestVersions.layer), Layer.provideMerge(McpProviderSessions.layer), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), ); const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( @@ -2630,6 +3040,72 @@ it.layer( }); describe("checkClaudeProviderStatus", () => { + it.effect("adds models Claude Code reports that the catalog does not know", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + { + ...defaultClaudeSettings, + customModels: [{ slug: "gateway/kimi-3[1m]", name: "Kimi (mine)" }], + }, + claudeCapabilities({ + models: [ + { value: "default", displayName: "Default", description: "" }, + { value: "sonnet", displayName: "Sonnet", description: "" }, + { value: "claude-haiku-4-5[1m]", displayName: "Haiku", description: "" }, + { + value: "best", + resolvedModel: "claude-opus-5[1m]", + displayName: "Best", + description: "", + }, + { value: "gateway/kimi-3[1m]", displayName: "Kimi 3", description: "" }, + { value: "gateway/glm-5[1m]", displayName: "GLM 5", description: "" }, + { value: "gateway/glm-5", displayName: "GLM 5 again", description: "" }, + { + value: "gateway/claude-relay", + displayName: "Claude Relay", + description: "", + supportsEffort: true, + supportedEffortLevels: ["low", "high"], + }, + ], + }), + ); + const catalogSlugs = new Set(BUNDLED_CLAUDE_MODEL_CATALOG.models.map((m) => m.model.slug)); + const added = status.models.filter((model) => !catalogSlugs.has(model.slug)); + + assert.deepStrictEqual( + added.map((model) => [model.slug, model.name, model.isCustom]), + [ + ["gateway/glm-5[1m]", "GLM 5", false], + ["gateway/claude-relay", "Claude Relay", false], + ["gateway/kimi-3[1m]", "Kimi (mine)", true], + ], + ); + assert.deepStrictEqual(added[0]?.capabilities?.optionDescriptors, []); + assert.deepStrictEqual(added[1]?.capabilities?.optionDescriptors, [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "default", label: "Default", isDefault: true }, + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }, + ]); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("reports a logged-out CLI as unauthenticated", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index e8a13a0617c34..fb6aeaac26b63 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -81,6 +81,17 @@ export class ProviderRegistry extends Context.Service< readonly fresh?: boolean; }) => Effect.Effect>; + /** + * Drop T3-owned model caches before an explicit model refresh. Pass the + * target of the refresh that follows. Background and ordinary refreshes + * keep their cache timers and must not call this. + */ + readonly invalidateModelCaches: (input: { + readonly instanceId?: ProviderInstanceId | undefined; + readonly cwd?: string | undefined; + readonly fresh?: boolean | undefined; + }) => Effect.Effect; + readonly getProviderMaintenanceCapabilitiesForInstance: ( instanceId: ProviderInstanceId, provider: ProviderDriverKind, @@ -433,10 +444,6 @@ export const layer = Layer.effect( ); const syncSemaphore = yield* Semaphore.make(1); - const getLiveSources: Effect.Effect> = Ref.get( - liveSubsRef, - ).pipe(Effect.map((map) => Array.from(map.values(), buildSnapshotSource))); - const persistProvider = Effect.fn("ProviderRegistry.persistProvider")(function* ( provider: ServerProvider, ) { @@ -590,15 +597,25 @@ export const layer = Layer.effect( }, ); - const refreshOneSource = Effect.fn("refreshOneSource")(function* ( - providerSource: ProviderSnapshotSource, - ) { - return yield* providerSource.refresh.pipe( - Effect.flatMap((nextProvider) => - correlateSnapshotWithSource(providerSource, nextProvider).pipe( - Effect.flatMap(syncProvider), - ), - ), + const isCurrentInstance = (instance: ProviderInstance) => + instanceRegistry + .getInstance(instance.instanceId) + .pipe(Effect.map((current) => current === instance)); + + const refreshOneSource = Effect.fn("refreshOneSource")(function* (instance: ProviderInstance) { + const providerSource = buildSnapshotSource(instance); + // Enrichment can publish a newer snapshot before refresh returns its pre-enrichment probe. + const probe = yield* providerSource.refresh.pipe( + Effect.andThen(providerSource.getSnapshot), + Effect.exit, + ); + // Settings can replace or remove the instance mid-probe; its late result or failure is stale. + if (!(yield* isCurrentInstance(instance))) { + return yield* Ref.get(providersRef); + } + const nextProvider = yield* probe; + return yield* correlateSnapshotWithSource(providerSource, nextProvider).pipe( + Effect.flatMap(syncProvider), ); }); @@ -627,9 +644,9 @@ export const layer = Layer.effect( // Detach shared passes so the first caller's interruption cannot cancel other callers' work. // Syncing first keeps a refresh right after a rebuild from probing the replaced instance. yield* syncLiveSourcesAndContinue.pipe( - Effect.andThen(getLiveSources), - Effect.flatMap((sources) => - Effect.forEach(sources, (source) => refreshOneSource(source), { + Effect.andThen(Ref.get(liveSubsRef)), + Effect.flatMap((instances) => + Effect.forEach(instances.values(), (instance) => refreshOneSource(instance), { concurrency: "unbounded", discard: true, }), @@ -650,27 +667,22 @@ export const layer = Layer.effect( if (provider === undefined) { return yield* refreshAll(); } - const defaultInstanceId = defaultInstanceIdForDriver(provider); - const sources = yield* getLiveSources; - const providerSource = sources.find( - (candidate) => candidate.instanceId === defaultInstanceId, - ); - if (!providerSource) { + const instance = (yield* Ref.get(liveSubsRef)).get(defaultInstanceIdForDriver(provider)); + if (!instance) { return yield* Ref.get(providersRef); } - return yield* refreshOneSource(providerSource); + return yield* refreshOneSource(instance); }); const refreshInstance = Effect.fn("refreshInstance")(function* ( instanceId: ProviderInstanceId, ) { yield* syncLiveSourcesAndContinue; - const sources = yield* getLiveSources; - const providerSource = sources.find((candidate) => candidate.instanceId === instanceId); - if (!providerSource) { + const instance = (yield* Ref.get(liveSubsRef)).get(instanceId); + if (!instance) { return yield* Ref.get(providersRef); } - return yield* refreshOneSource(providerSource); + return yield* refreshOneSource(instance); }); const reloadSecretsAndRefresh = Effect.fn("reloadSecretsAndRefresh")(function* ( @@ -731,15 +743,18 @@ export const layer = Layer.effect( yield* Effect.forEach( instances.filter((instance) => !failedInstances.has(instance.instanceId)), (instance) => - refreshOneSource(buildSnapshotSource(instance)).pipe( + refreshOneSource(instance).pipe( Effect.catchCause((cause) => recoverInstanceFailure(instance.instanceId, cause)), ), { concurrency: "unbounded", discard: true }, ); + // A replaced instance's cached row did not come from this pass's probe. + const refreshedInstances = yield* Effect.filter( + instances.filter((instance) => !failedInstances.has(instance.instanceId)), + isCurrentInstance, + ); const refreshedInstanceIds = new Set( - instances - .filter((instance) => !failedInstances.has(instance.instanceId)) - .map((instance) => instance.instanceId), + refreshedInstances.map((instance) => instance.instanceId), ); return (yield* Ref.get(providersRef)).filter((candidate) => refreshedInstanceIds.has(candidate.instanceId), @@ -1026,6 +1041,34 @@ export const layer = Layer.effect( return yield* Fiber.join(claim.scan); }); + const invalidateModelCaches = Effect.fn("ProviderRegistry.invalidateModelCaches")(function* ( + input: Parameters[0], + ) { + yield* manifestService.forceRefresh; + const instances = yield* instanceRegistry.listInstances; + yield* Effect.forEach( + instances.filter( + (instance) => input.instanceId === undefined || input.instanceId === instance.instanceId, + ), + (instance) => + Effect.gen(function* () { + yield* instance.invalidateCaches ?? Effect.void; + // Instance and full refreshes resolve installer metadata fresh + // themselves; a cached workspace refresh needs it resolved here. + const maintenance = yield* instance.snapshot.resolveMaintenance({ + fresh: + input.instanceId !== undefined && input.cwd !== undefined && input.fresh !== true, + }); + yield* Effect.forEach( + latestProviderVersionCacheKeys(maintenance), + latestVersions.invalidate, + { discard: true }, + ); + }), + { concurrency: "unbounded", discard: true }, + ); + }); + return { getProviders: Ref.get(providersRef), refresh: (provider?: ProviderDriverKind) => @@ -1037,6 +1080,7 @@ export const layer = Layer.effect( refreshWorkspaceSnapshot: (input) => refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), refreshAfterUpdate, + invalidateModelCaches, getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/documentTextWorker.ts b/apps/server/src/provider/documentTextWorker.ts index 569493906d385..3f9ed4f1ed228 100644 --- a/apps/server/src/provider/documentTextWorker.ts +++ b/apps/server/src/provider/documentTextWorker.ts @@ -14,7 +14,22 @@ const CELL_END = Symbol("cell-end"); type TextPart = string | typeof CELL_START | typeof CELL_END | { fallback: string }; const TRUNCATED = "\n\n[Extracted text truncated at document extraction limits.]"; +// Parts the document never displays: tracked deletions and moves, field codes, phonetic guides, +// paragraph properties such as tab-stop definitions. +const HIDDEN = new Set([ + "script", + "style", + "binary-data", + "rPh", + "delText", + "moveFrom", + "instrText", + "delInstrText", + "tracked-changes", + "pPr", +]); +// @effect-diagnostics-next-line extendsNativeError:off - this worker loads no Effect runtime; Truncation is a local throw sentinel. class Truncation extends Error {} interface XmlNode { @@ -22,6 +37,8 @@ interface XmlNode { attrs: Record; children: Array; spaceCount?: number; + hasText?: boolean; + hasTextChoice?: boolean; } function xml(source: string, odfSpaces = false, htmlEntities = false): XmlNode { @@ -47,10 +64,22 @@ function xml(source: string, odfSpaces = false, htmlEntities = false): XmlNode { stack.push(node); }); parser.on("closetag", () => { - stack.pop(); + const node = stack.pop()!; + const parent = stack.at(-1)!; + // Markup-compatibility fallbacks repeat their mc:Choice content, e.g. Word text boxes. + // Keep one when no Choice carries text, such as a chart with a VML caption. + if (node.name === "Fallback" && parent.hasTextChoice) { + parent.children.pop(); + return; + } + if (!node.hasText) return; + parent.hasText = true; + if (node.name === "Choice") parent.hasTextChoice = true; }); const text = (value: string) => { - stack.at(-1)!.children.push(value); + const node = stack.at(-1)!; + node.children.push(value); + if (value.trim() !== "") node.hasText = true; }; parser.on("text", text); parser.on("cdata", text); @@ -67,7 +96,7 @@ function* all(node: XmlNode, name: string | readonly string[]): Generator { - if (["script", "style", "binary-data", "rPh"].includes(node.name)) return; + if (HIDDEN.has(node.name)) return; if (["tab", "cell"].includes(node.name)) yield "\t"; else if (["br", "line-break", "cr"].includes(node.name)) yield "\n"; else if (node.spaceCount !== undefined) { @@ -80,7 +109,7 @@ function* text(node: XmlNode): Generator { } } function* paragraphs(node: XmlNode): Generator { - if (["script", "style", "binary-data"].includes(node.name)) return; + if (HIDDEN.has(node.name)) return; for (const child of node.children) { if (typeof child === "string") yield child; else if (["br", "line-break", "cr"].includes(child.name)) yield "\n"; @@ -137,11 +166,7 @@ function odfValue(cell: XmlNode): string { function hasCellText(cell: XmlNode): boolean { if (odfValue(cell).trim()) return true; // Space markers can have enormous repeat counts; emptiness never requires expanding them. - if ( - ["script", "style", "binary-data", "rPh"].includes(cell.name) || - cell.spaceCount !== undefined - ) - return false; + if (HIDDEN.has(cell.name) || cell.spaceCount !== undefined) return false; return cell.children.some((child) => typeof child === "string" ? !!child.trim() : hasCellText(child), ); diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 59753c7f51458..8c9969a1d0aa1 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-28T08:10:39Z", + "updatedAt": "2026-11-02T23:47:11Z", "compatibility": [ { "driver": "codex", @@ -25,7 +25,7 @@ { "driver": "claudeAgent", "t3CodeRange": ">=0.0.42", - "recommendedRange": ">=2.1.280", + "recommendedRange": ">=2.1.301", "ranges": [ { "range": ">=2.1.280", "status": "supported" }, { "range": ">=2.1.111 <2.1.280", "status": "graceful" }, @@ -83,6 +83,12 @@ { "range": "=1.1.1", "status": "supported" } ] }, + { + "driver": "kiro", + "t3CodeRange": ">=0.0.46", + "recommendedRange": ">=2.27.0", + "ranges": [{ "range": ">=2.27.0", "status": "supported" }] + }, { "driver": "pi", "t3CodeRange": ">=0.0.46", diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 6f3e49e4d4c46..77a44c7fa29a5 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -211,6 +211,7 @@ function makeRegistry( ); }, refreshWorkspaceSnapshot: () => Ref.get(providersRef), + invalidateModelCaches: () => Effect.void, getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), setProviderMaintenanceActionState, diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index ee7709e710eda..d3fe7fdf86739 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -10,7 +10,6 @@ import { import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -79,10 +78,13 @@ export class ProviderMaintenanceRunner extends Context.Service< ProviderMaintenanceRunnerShape >()("t3/provider/providerMaintenanceRunner") {} -class ProviderMaintenanceCommandError extends Data.TaggedError("ProviderMaintenanceCommandError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +class ProviderMaintenanceCommandError extends Schema.TaggedError()( + "ProviderMaintenanceCommandError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} interface VerifiedProviderRefresh { readonly providers: ReadonlyArray; diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 6566748349f6a..a697357f5f273 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -34,6 +34,7 @@ const BUILT_IN_DRIVER_ORDER: ReadonlyArray = [ "opencode", "antigravity", "fx", + "kiro", ]; const driverRank = (driver: string): number => { diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index a4008acc18efb..f75ecd141e71a 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -14,6 +14,7 @@ const makeProviderRegistryMock = ( reloadSecretsAndRefresh: () => Effect.succeed(providers), refreshAfterUpdate: () => Effect.succeed(providers), refreshWorkspaceSnapshot: () => Effect.succeed(providers), + invalidateModelCaches: () => Effect.void, getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), setProviderMaintenanceActionState: () => Effect.succeed(providers), diff --git a/apps/server/src/pullRequest/PullRequestReadCache.test.ts b/apps/server/src/pullRequest/PullRequestReadCache.test.ts index bb6eb38f16146..b73774700fcf8 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.test.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.test.ts @@ -1,13 +1,19 @@ import * as NodeV8 from "node:v8"; import * as NodeVM from "node:vm"; import { assert, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { PullRequestOperationError } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; +import * as TestClock from "effect/testing/TestClock"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; NodeV8.setFlagsFromString("--expose-gc"); @@ -16,7 +22,7 @@ const collectGarbage = NodeVM.runInNewContext("gc") as () => void; const cacheLayer = (directory: string) => PullRequestReadCache.make.pipe(Effect.provide(KeyValueStore.layerFileSystem(directory))); -it.layer(NodeServices.layer)("PR filesystem cache", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PR filesystem cache", (it) => { it.effect("clears in-flight reads before a new service can reuse them", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -46,6 +52,24 @@ it.layer(NodeServices.layer)("PR filesystem cache", (it) => { }), ); + it.effect("prunes expired entry files the store wrote", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + const cache = yield* cacheLayer(directory); + yield* cache.get("summary", Effect.succeed("value")); + const [entry] = yield* fs.readDirectory(directory); + assert.isDefined(entry); + const info = yield* fs.stat(path.join(directory, entry)); + yield* TestClock.setTime( + Option.getOrThrow(info.mtime).getTime() + Duration.toMillis(Duration.days(2)), + ); + yield* PullRequestReadCache.pruneExpiredEntryFiles(directory); + assert.deepStrictEqual(yield* fs.readDirectory(directory), []); + }), + ); + it.effect("invalidates only the changed scope across restarts and coalesces its next reads", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index 6ea0aea8f76b6..f3ec665ca9786 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -3,7 +3,8 @@ import * as Clock from "effect/Clock"; import * as Equal from "effect/Equal"; import * as Hash from "effect/Hash"; import { PullRequestOperationError, PullRequestUnavailableError } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Hex from "effect/encoding/Hex"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -25,9 +26,10 @@ import { detachStackFrame } from "../git/detachStackFrame.ts"; import { forkParked } from "../serverActivation.ts"; const CONCURRENT_READS = 512; -// Entry filenames have the store-id prefix; the scope revisions file does not. +// Entry filenames are the URI-encoded `:`; the scope revisions file has +// no store id. Effect before 4.1 wrote ``, still pruned here. const STORE_ID = "pr-v2"; -const ENTRY_FILE_NAME = new RegExp(`^${STORE_ID}[0-9a-f]{64}$`); +const ENTRY_FILE_NAME = new RegExp(`^${STORE_ID}(?:%3A)?[0-9a-f]{64}$`); /** * A file untouched for a day is long expired; the day only leaves slack for * clock changes. @@ -80,12 +82,13 @@ export class PullRequestReadCache extends Context.Service< export const make = Effect.gen(function* () { const backing = yield* KeyValueStore.KeyValueStore; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; + const digester = yield* Digest.Digest; const clock = yield* Clock.Clock; let enabled = true; const lock = yield* Semaphore.make(CONCURRENT_READS); const digest = (key: string) => - crypto.digest("SHA-256", new TextEncoder().encode(key)).pipe(Effect.map(Hex.encode)); + digester.digest(Digest.sha256, new TextEncoder().encode(key)).pipe(Effect.map(Hex.encode)); const revisions = yield* Cache.makeWith( () => backing.get("revisions").pipe( @@ -155,7 +158,6 @@ export const make = Effect.gen(function* () { ).payload; }).pipe( Effect.catchTags({ - PlatformError: () => read, KeyValueStoreError: () => read, PersistenceError: () => read, SchemaError: () => read, @@ -170,7 +172,7 @@ export const make = Effect.gen(function* () { const next = Object.fromEntries( Object.entries(current).filter(([, value]) => value.expiresAt > now), ); - next[scope] = { revision: yield* crypto.randomUUIDv4(), expiresAt: now + 60_000 }; + next[scope] = { revision: yield* random.uuidV4(), expiresAt: now + 60_000 }; const encoded = yield* Schema.encodeEffect(revisionCodec)(next); yield* backing.set("revisions", encoded); yield* Cache.set(revisions, undefined, next); @@ -190,33 +192,33 @@ export const make = Effect.gen(function* () { * The persisted cache drops an expired entry only when it is read again, so * files for PRs nobody reopens would otherwise stay forever. */ -const pruneExpiredEntryFiles = Effect.fn("PullRequestReadCache.pruneExpiredEntryFiles")(function* ( - directory: string, -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(ENTRY_FILE_MAX_AGE); - const entries = (yield* fileSystem.readDirectory(directory)).filter((name) => - ENTRY_FILE_NAME.test(name), - ); - const [, failures] = yield* Effect.partition(entries, (name) => { - const entryPath = path.join(directory, name); - return fileSystem.stat(entryPath).pipe( - Effect.flatMap((info) => - Option.exists(info.mtime, (mtime) => mtime.getTime() < cutoff) - ? fileSystem.remove(entryPath) - : Effect.void, - ), - Effect.catchReason("PlatformError", "NotFound", () => Effect.void), +export const pruneExpiredEntryFiles = Effect.fn("PullRequestReadCache.pruneExpiredEntryFiles")( + function* (directory: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(ENTRY_FILE_MAX_AGE); + const entries = (yield* fileSystem.readDirectory(directory)).filter((name) => + ENTRY_FILE_NAME.test(name), ); - }); - if (failures.length > 0) { - yield* Effect.logWarning("Failed to prune some PR cache files", { - failed: failures.length, - cause: failures[0], + const [, failures] = yield* Effect.partition(entries, (name) => { + const entryPath = path.join(directory, name); + return fileSystem.stat(entryPath).pipe( + Effect.flatMap((info) => + Option.exists(info.mtime, (mtime) => mtime.getTime() < cutoff) + ? fileSystem.remove(entryPath) + : Effect.void, + ), + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + ); }); - } -}); + if (failures.length > 0) { + yield* Effect.logWarning("Failed to prune some PR cache files", { + failed: failures.length, + cause: failures[0], + }); + } + }, +); export const layer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts index e5f99df9d9354..fa4a005d45ff1 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Context from "effect/Context"; @@ -222,7 +223,7 @@ const layerBase = Layer.mergeAll( Layer.provideMerge(VcsProcess.layer), ), SqlitePersistence.layerMemory, -).pipe(Layer.provideMerge(NodeServices.layer)); +).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); type Repositories = Effect.Success; @@ -467,7 +468,7 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic ), ); - it.effect("keeps excerpts of source lines a configured diff driver would convert away", () => + it.effect("keeps excerpts despite the checkout's diff driver and a failing hook", () => Effect.scoped( Effect.gen(function* () { const repositories = yield* makeRepositories; @@ -477,6 +478,9 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic "feature.txt diff=blank\n", ); yield* git(repositories.workspace, ["config", "diff.blank.textconv", "true"]); + const hook = `${repositories.workspace}/.git/hooks/post-checkout`; + yield* fs.writeFileString(hook, "#!/bin/sh\nexit 1\n"); + yield* fs.chmod(hook, 0o755); const stored = yield* Deferred.make(); const context = yield* Layer.build( makeLayer({ @@ -502,9 +506,10 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic context, PullRequestReviewGuideService.PullRequestReviewGuideService, ); - yield* service.generate(reference); - yield* Deferred.await(stored); - const result = yield* service.get(reference); + const runs = yield* watchRuns; + yield* runs.generate(service, reference); + const result = yield* runs.settled(service, reference); + assert.notStrictEqual(result.run?._tag, "Failed", JSON.stringify(result.run)); assert.deepStrictEqual(result.guide?.stops[0]?.excerpts, [ { path: "feature.txt", side: "new", start: 1, end: 3, caption: "" }, ]); diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index 2da7350f2ebfd..fe994e7ab3ee6 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -306,7 +306,17 @@ const make = Effect.gen(function* () { yield* git.execute({ operation: "PullRequestReviewGuideService.addWorktree", cwd: workspaceRoot, - args: ["worktree", "add", "--detach", "--quiet", path, resolvedHead.commitSha], + // Scratch checkout for reading the change: repository hooks (installs, LFS) only slow or fail it. + args: [ + "-c", + "core.hooksPath=/dev/null", + "worktree", + "add", + "--detach", + "--quiet", + path, + resolvedHead.commitSha, + ], timeoutMs: 5 * 60_000, }); yield* Effect.addFinalizer(() => diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 1b3a3fd073082..e013b0b3ec1cb 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,4 +1,5 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; import { assert, expect, it } from "@effect/vitest"; @@ -289,7 +290,7 @@ function makeService(input: { PullRequestFilesViewed.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe( Layer.provide(KeyValueStore.layerMemory), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), ), @@ -3043,19 +3044,19 @@ it.effect("finishes two presses on one file in the order they were made", () => ], }); - const tick = service + const tick = yield* service .setFilesViewed({ ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true }], }) - .pipe(Effect.runFork); + .pipe(Effect.forkChild({ startImmediately: true })); yield* TestClock.adjust("1 second"); - const untick = service + const untick = yield* service .setFilesViewed({ ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: false }], }) - .pipe(Effect.runFork); + .pipe(Effect.forkChild({ startImmediately: true })); yield* TestClock.adjust("1 second"); yield* Deferred.succeed(held, undefined); yield* Fiber.join(tick); diff --git a/apps/server/src/requestUrl.ts b/apps/server/src/requestUrl.ts new file mode 100644 index 0000000000000..a1a0371c5f97f --- /dev/null +++ b/apps/server/src/requestUrl.ts @@ -0,0 +1,14 @@ +import * as Option from "effect/Option"; +import { HttpServerRequest } from "effect/http"; + +/** + * `HttpServerRequest.toURL` that honors `X-Forwarded-Proto: https`. Effect 4.1 + * stopped reading it, but TLS-terminating proxies in front of the server + * (Tailscale Serve, tunnels) rely on it for OAuth, DPoP and pairing URLs. + */ +export const requestUrl = (request: HttpServerRequest.HttpServerRequest): Option.Option => + HttpServerRequest.toURL( + request.headers["x-forwarded-proto"] === "https" && request.protocol !== "https" + ? request.modify({ protocol: "https" }) + : request, + ); diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index 87230a382af01..55f5cc4e06f51 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -29,6 +30,8 @@ function makeLayer(input: { }) { const projectRoots = input.projectRoots ?? []; return ReviewService.layer.pipe( + // Coder: review file snapshots draw ids and hashes from SecureRandom and Digest. + Layer.provide(NodeCrypto.layer), Layer.provide(KnownWorkspaces.layer), Layer.provide( Layer.effectDiscard( @@ -269,6 +272,7 @@ describe("ReviewService", () => { const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); const contents = { oldContents: "before\n", newContents: "after\n" }; const layer = ReviewService.layer.pipe( + Layer.provide(NodeCrypto.layer), Layer.provide( Layer.mock(KnownWorkspaces.KnownWorkspaces)({ contains: () => Effect.succeed(false), @@ -337,6 +341,7 @@ describe("ReviewService", () => { const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); const contents = "repeated source line\n".repeat(1_000); const layer = ReviewService.layer.pipe( + Layer.provide(NodeCrypto.layer), Layer.provide( Layer.mock(KnownWorkspaces.KnownWorkspaces)({ contains: () => Effect.succeed(false), @@ -395,6 +400,7 @@ describe("ReviewService", () => { const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); const layer = ReviewService.layer.pipe( + Layer.provide(NodeCrypto.layer), Layer.provide( Layer.mock(KnownWorkspaces.KnownWorkspaces)({ contains: () => Effect.succeed(false), @@ -475,6 +481,7 @@ describe("ReviewService", () => { const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); const layer = ReviewService.layer.pipe( + Layer.provide(NodeCrypto.layer), Layer.provide( Layer.mock(KnownWorkspaces.KnownWorkspaces)({ contains: () => Effect.succeed(false), @@ -534,6 +541,7 @@ describe("ReviewService", () => { const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); let readCount = 0; const layer = ReviewService.layer.pipe( + Layer.provide(NodeCrypto.layer), Layer.provide( Layer.mock(KnownWorkspaces.KnownWorkspaces)({ contains: () => Effect.succeed(false), diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 1bdfe31db1bc6..ebba8d684c5f2 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -1,10 +1,13 @@ +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; 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 NodeCrypto from "node:crypto"; +import * as Schema from "effect/Schema"; import * as NodeZlib from "node:zlib"; import { @@ -57,6 +60,8 @@ export class ReviewService extends Context.Service< } >()("t3/review/ReviewService") {} +const isGitCommandError = Schema.is(GitCommandError); + /** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -65,6 +70,8 @@ export const make = Effect.gen(function* () { const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; const knownWorkspaces = yield* KnownWorkspaces.KnownWorkspaces; + const random = yield* SecureRandom.SecureRandom; + const digest = yield* Digest.Digest; // Coder: review file snapshots live in helper memory (64 MiB, 256 entries, two-minute idle TTL). const snapshotCache = new Map(); let snapshotCacheBytes = 0; @@ -85,16 +92,18 @@ export const make = Effect.gen(function* () { } }; - const cacheSnapshot = (contents: string, filePath: string) => { - const now = Date.now(); - pruneExpiredSnapshots(now); + const cacheSnapshot = Effect.fnUntraced(function* (contents: string, filePath: string) { const data = Buffer.from(contents, "utf8"); if (data.byteLength > MAX_REVIEW_DIFF_FILE_BYTES) { - throw new ReviewDiffFileTooLargeError({ + return yield* new ReviewDiffFileTooLargeError({ path: filePath, maxBytes: MAX_REVIEW_DIFF_FILE_BYTES, }); } + const snapshotId = yield* random.uuidV4(); + const hash = yield* digest.digestHex(Digest.sha256, data); + const now = yield* Clock.currentTimeMillis; + pruneExpiredSnapshots(now); while ( snapshotCache.size > 0 && (snapshotCache.size >= snapshotCacheEntryLimit || @@ -104,12 +113,10 @@ export const make = Effect.gen(function* () { if (oldestId === undefined) break; removeSnapshot(oldestId); } - const snapshotId = NodeCrypto.randomUUID(); - const hash = NodeCrypto.createHash("sha256").update(data).digest("hex"); snapshotCache.set(snapshotId, { data, expiresAt: now + snapshotCacheTtlMs }); snapshotCacheBytes += data.byteLength; return { snapshotId, totalBytes: data.byteLength, contentHash: hash }; - }; + }); const canonicalizePath = (value: string) => { const resolvedPath = path.resolve(value); @@ -210,37 +217,21 @@ export const make = Effect.gen(function* () { )(function* (input) { return yield* Effect.gen(function* () { const contents = yield* getDiffFileContents(input); - return yield* Effect.try({ - try: () => { - const oldFile = - input.changeType === "new" || input.changeType === "rename-pure" - ? null - : cacheSnapshot(contents.oldContents, input.oldPath); - try { - return { - _tag: "opened" as const, - oldFile, - newFile: - input.changeType === "deleted" - ? null - : cacheSnapshot(contents.newContents, input.newPath), - }; - } catch (cause) { - if (oldFile !== null) removeSnapshot(oldFile.snapshotId); - throw cause; - } - }, - catch: (cause) => - cause instanceof GitCommandError || cause instanceof ReviewDiffFileTooLargeError - ? cause - : new GitCommandError({ - operation: "ReviewService.openDiffFileContents", - command: "snapshot review file", - cwd: input.cwd, - detail: "Could not create a review file snapshot.", - cause, - }), - }); + const oldFile = + input.changeType === "new" || input.changeType === "rename-pure" + ? null + : yield* cacheSnapshot(contents.oldContents, input.oldPath); + const newFile = + input.changeType === "deleted" + ? null + : yield* cacheSnapshot(contents.newContents, input.newPath).pipe( + Effect.onError(() => + Effect.sync(() => { + if (oldFile !== null) removeSnapshot(oldFile.snapshotId); + }), + ), + ); + return { _tag: "opened" as const, oldFile, newFile }; }).pipe( Effect.catchTag("ReviewDiffFileTooLargeError", ({ path: filePath, maxBytes }) => Effect.succeed({ _tag: "tooLarge" as const, path: filePath, maxBytes }), @@ -250,10 +241,10 @@ export const make = Effect.gen(function* () { const readDiffFileChunk: ReviewService["Service"]["readDiffFileChunk"] = Effect.fn( "ReviewService.readDiffFileChunk", - )((input) => - Effect.try({ + )(function* (input) { + const now = yield* Clock.currentTimeMillis; + return yield* Effect.try({ try: () => { - const now = Date.now(); pruneExpiredSnapshots(now); const snapshot = snapshotCache.get(input.snapshotId); if (!snapshot) { @@ -292,7 +283,7 @@ export const make = Effect.gen(function* () { }; }, catch: (cause) => - cause instanceof GitCommandError + isGitCommandError(cause) ? cause : new GitCommandError({ operation: "ReviewService.readDiffFileChunk", @@ -301,8 +292,8 @@ export const make = Effect.gen(function* () { detail: "Could not read the review file snapshot.", cause, }), - }), - ); + }); + }); return ReviewService.of({ getBranchCommits, diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index 75650b55354b4..59cdb4433b404 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -395,6 +395,8 @@ it.effect("thread cleanup notifies subscribers and a finishing run cannot recrea const run = yield* service.runNow({ id: task.id }).pipe(Effect.forkChild); yield* Deferred.await(started); yield* Deferred.await(running); + const refused = yield* service.runNow({ id: task.id }).pipe(Effect.flip); + assert.equal(refused.message, "Schedule task is already running."); yield* service.deleteForThread(threadId); yield* Deferred.await(removed); yield* Deferred.succeed(release, undefined); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index f25f260119b51..fd1c72f9a050a 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -26,8 +26,9 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; +import * as Digest from "effect/crypto/Digest"; +import * as Mac from "effect/crypto/Mac"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -60,6 +61,7 @@ import { acceptImmediateWebhookDedupe, releaseWebhookReplay, decodePendingTask, + encodePendingTask, decodePendingRequest, dedupeError, } from "./webhookDedupe.ts"; @@ -113,9 +115,10 @@ export class ScheduledTaskWebhookOrigin extends Context.Reference {} +class WebhookDeliverySkipped extends Schema.TaggedError()( + "WebhookDeliverySkipped", + { reason: Schema.String }, +) {} interface RateWindow { readonly accepted: ReadonlyArray<{ readonly at: number }>; @@ -325,9 +328,8 @@ function requireFutureOnce( } function errorMessage(error: unknown): string { - if (Cause.isCause(error)) return Cause.pretty(error); - if (error instanceof Error) return error.message; - return String(error); + const failure = Cause.isCause(error) ? Cause.squash(error) : error; + return failure instanceof Error ? failure.message : String(failure); } function webhookPath(taskId: string, token: string): string { @@ -427,7 +429,9 @@ export const layer = Layer.effect( ScheduledTaskService, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; + const digest = yield* Digest.Digest; + const mac = yield* Mac.Mac; const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; const threadManagement = yield* ThreadManagementService.ThreadManagementService; const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; @@ -550,7 +554,7 @@ export const layer = Layer.effect( ), ); - const newWebhookToken = crypto.randomBytes(32).pipe( + const newWebhookToken = random.randomBytes(32).pipe( Effect.map((bytes) => Buffer.from(bytes).toString("base64url")), Effect.mapError((cause) => taskError("Could not generate webhook token.", { cause })), ); @@ -855,7 +859,6 @@ export const layer = Layer.effect( : `${active.id}:webhook:${webhook.deliveryId}`; const commandId = CommandId.make(`scheduled-task:${fireKey}`); const messageId = MessageId.make(`scheduled-task-message:${fireKey}`); - // A webhook prompt was rendered from the row when the request arrived. const prompt = webhook?.prompt ?? active.prompt; // A bound thread can move to another project (No project to a project) while @@ -1177,8 +1180,8 @@ export const layer = Layer.effect( const now = yield* localNow; const uuid = input.commandId === undefined - ? yield* crypto - .randomUUIDv4() + ? yield* random + .uuidV4() .pipe( Effect.mapError((cause) => taskError("Could not generate schedule task id.", { cause }), @@ -1438,7 +1441,9 @@ export const layer = Layer.effect( } return yield* runTask(task, "manual").pipe( Effect.mapError((cause) => - taskError("Could not run schedule task.", { taskId: input.id, cause }), + isScheduledTaskError(cause) + ? cause + : taskError("Could not run schedule task.", { taskId: input.id, cause }), ), ); }); @@ -1859,7 +1864,9 @@ export const layer = Layer.effect( if ( row === undefined || row.webhook_token === null || - !constantTimeEquals(request.token, row.webhook_token) + !(yield* constantTimeEquals(request.token, row.webhook_token).pipe( + Effect.provideService(Digest.Digest, digest), + )) ) { return yield* notFound; } @@ -1883,8 +1890,8 @@ export const layer = Layer.effect( observeDelivery(request, outcome, receivedAt, now); const deliveryId = ScheduledTaskWebhookDeliveryId.make( request.relayDeliveryId === undefined - ? `delivery:${yield* crypto - .randomUUIDv4() + ? `delivery:${yield* random + .uuidV4() .pipe( Effect.mapError((cause) => taskError("Could not generate delivery id.", { cause })), )}` @@ -1974,12 +1981,12 @@ export const layer = Layer.effect( if (signature !== null) { const verified = row.webhook_secret !== null && - verifyWebhookSignature({ + (yield* verifyWebhookSignature({ signature, secret: row.webhook_secret, headers: request.headers, body: request.body, - }); + }).pipe(Effect.provideService(Mac.Mac, mac))); if (!verified) { yield* log("rejected_signature"); yield* observe("rejected_signature"); @@ -2113,7 +2120,7 @@ export const layer = Layer.effect( claimAcquired = false; return { _tag: "accepted", deliveryId, outcome } as const; } - const frameId = yield* crypto.randomBytes(6).pipe( + const frameId = yield* random.randomBytes(6).pipe( Effect.map((bytes) => Buffer.from(bytes).toString("hex")), Effect.mapError((cause) => taskError("Could not generate frame id.", { cause })), ); @@ -2264,10 +2271,21 @@ export const layer = Layer.effect( continue; } const prepared = yield* Effect.gen(function* () { - const task = yield* decodePendingTask(entry.task_json); + // A running row was already sent, so recovery keeps the prompt it was sent with. + const current = entry.state === "dispatching" ? yield* findTask(taskId) : null; + const snapshot = yield* decodePendingTask(entry.task_json); + // The snapshot keeps the identity runTask checks; the prompt is the one saved now. + const task = + current?.createdAt === snapshot.createdAt && current.prompt !== snapshot.prompt + ? { ...snapshot, prompt: current.prompt } + : snapshot; const request = yield* decodePendingRequest(entry.request_json); - const frameId = Buffer.from(yield* crypto.randomBytes(6)).toString("hex"); - return { task, rendered: renderWebhookPrompt(task.prompt, request, frameId) }; + const frameId = Buffer.from(yield* random.randomBytes(6)).toString("hex"); + return { + task, + taskJson: task === snapshot ? null : yield* encodePendingTask(task), + rendered: renderWebhookPrompt(task.prompt, request, frameId), + }; }).pipe(Effect.exit); if (Exit.isFailure(prepared)) { if (Cause.hasInterruptsOnly(prepared.cause)) @@ -2280,11 +2298,13 @@ export const layer = Layer.effect( yield* remove; continue; } - const { task, rendered } = prepared.value; + const { task, taskJson, rendered } = prepared.value; if (rendered.prompt.trim().length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { yield* markDeliveryFailed(entry.delivery_id, PROMPT_TOO_LONG); yield* releaseFailedDelivery(entry.delivery_id); } else { + if (taskJson !== null) + yield* sql`UPDATE scheduled_task_webhook_pending SET task_json = ${taskJson} WHERE delivery_id = ${entry.delivery_id}`; yield* sql`UPDATE scheduled_task_webhook_deliveries SET outcome = 'accepted', error = NULL, rendered_prompt = ${rendered.prompt.slice(0, WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT)}, missing_fields_json = ${encodeMissingFieldsJson(rendered.missing)} WHERE delivery_id = ${entry.delivery_id}`; if (!(yield* runWebhookDelivery(task, entry.delivery_id, rendered.prompt))) return; } diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index aaeb374bd0dd4..f1ca463ac0fc0 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -9,7 +9,7 @@ import { SecretRequestError, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -179,12 +179,14 @@ const withService = ( const launches = yield* Queue.unbounded(); const secretsByRef = new Map(); const layerDependencies = Layer.mergeAll( + NodePlatformCrypto.layerDigest, + NodePlatformCrypto.layerMac, options.frameGate === undefined && options.onFrame === undefined ? NodePlatformCrypto.layer : Layer.effect( - Crypto.Crypto, + SecureRandom.SecureRandom, Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; + const crypto = yield* SecureRandom.SecureRandom; return { ...crypto, randomBytes: (size: number) => @@ -971,6 +973,106 @@ it.effect.each([1, 3])( }, ); +it.effect("a coalesced window dispatches the prompt saved after it opened", () => { + const workers = new Map>(); + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const schedule = { + type: "webhook", + dedupe: { coalesceKey: "{{body.id}}", coalesceWindowSeconds: 10 }, + }; + const { task } = yield* service.upsert( + yield* webhookTaskInput({ prompt: "Use the original instructions.", schedule }), + ); + const bodyText = JSON.stringify({ id: "review" }); + yield* service.triggerWebhook( + requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }), + ); + yield* service.upsert( + yield* webhookTaskInput({ prompt: "Use the updated instructions.", schedule }), + ); + yield* TestClock.adjust("10 seconds"); + yield* workers.get("webhook-coalescing")!; + const launched = yield* Queue.take(launches); + assert.equal(launched.initialMessage?.text, "Use the updated instructions."); + const owner = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries[0]!; + const { delivery } = yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: owner.id, + }); + assert.equal(delivery.renderedPrompt, "Use the updated instructions."); + }), + { workers }, + ); +}); + +it.effect("retrying a running window keeps the prompt it was dispatched with", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const schedule = { + type: "webhook", + dedupe: { coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }; + const save = (prompt: string) => + Effect.flatMap(webhookTaskInput({ prompt, schedule }), service.upsert); + const { task } = yield* save("Prompt when the window opened"); + yield* service.triggerWebhook(requestFor(task)); + yield* save("Prompt at dispatch"); + yield* sql`CREATE TRIGGER fail_completion BEFORE UPDATE ON scheduled_tasks + WHEN NEW.last_run_status = 'succeeded' BEGIN SELECT RAISE(FAIL, 'completion unavailable'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + const launched = yield* Queue.take(launches); + assert.equal(launched.initialMessage?.text, "Prompt at dispatch"); + yield* save("Prompt saved after dispatch"); + yield* sql`DROP TRIGGER fail_completion`; + yield* drainPendingDispatch(workers); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + const owner = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries[0]!; + const { delivery } = yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: owner.id, + }); + assert.equal(delivery.renderedPrompt, "Prompt at dispatch"); + }), + { workers }, + ); +}); + +it.effect("a coalesced window fails its delivery when the task row cannot be read", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* sql`UPDATE scheduled_tasks SET model_selection_json = 'not json' WHERE task_id = ${task.id}`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + const [delivery] = yield* sql<{ + outcome: string; + error: string | null; + }>`SELECT outcome, error FROM scheduled_task_webhook_deliveries`; + assert.equal(delivery?.outcome, "dispatch_failed"); + // Failing before dispatch means a later successful read cannot send the stale snapshot. + assert.equal(delivery?.error, "The pending webhook payload could not be prepared."); + }), + { workers }, + ); +}); + it.effect("recovers a persisted coalescing window after restarting the service", () => { const workers = new Map>(); return withService( @@ -1239,6 +1341,7 @@ it.effect("start failures release both replay keys and relay claims for redelive yield* Queue.take(launches); assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_relay_deliveries`).length, 0); + assert.equal((yield* service.list()).tasks[0]!.lastRunError, "injected start failure"); const retry = yield* waitForSpan( service.triggerWebhook(request), "ScheduledTaskService.runWebhookDelivery", diff --git a/apps/server/src/scheduledTasks/webhookDedupe.ts b/apps/server/src/scheduledTasks/webhookDedupe.ts index 21d40b25ac5ed..76f04a97e6dfe 100644 --- a/apps/server/src/scheduledTasks/webhookDedupe.ts +++ b/apps/server/src/scheduledTasks/webhookDedupe.ts @@ -111,7 +111,7 @@ export const acceptImmediateWebhookDedupe = Effect.fn( }); const PendingTaskJson = Schema.fromJsonString(ScheduledTask); -const encodePendingTask = Schema.encodeEffect(PendingTaskJson); +export const encodePendingTask = Schema.encodeEffect(PendingTaskJson); export const decodePendingTask = Schema.decodeUnknownEffect(PendingTaskJson); const PendingRequestJson = Schema.fromJsonString( Schema.Struct({ diff --git a/apps/server/src/scheduledTasks/webhookVerification.test.ts b/apps/server/src/scheduledTasks/webhookVerification.test.ts index 330128bfb1a00..05b7bf3510bdd 100644 --- a/apps/server/src/scheduledTasks/webhookVerification.test.ts +++ b/apps/server/src/scheduledTasks/webhookVerification.test.ts @@ -1,7 +1,9 @@ -// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac. +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Node's HMAC is an independent oracle for the Effect implementation. import * as NodeCrypto from "node:crypto"; +import * as PlatformNodeCrypto from "@effect/platform-node/NodeCrypto"; import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; import { verifyWebhookSignature } from "./webhookVerification.ts"; @@ -12,44 +14,58 @@ const hmac = () => NodeCrypto.createHmac("sha256", secret).update(body); describe("verifyWebhookSignature", () => { const github = { header: "x-hub-signature-256", encoding: "hex", prefix: "sha256=" } as const; - it("rejects a tampered body, a wrong secret, a missing header and a missing prefix", () => { - const valid = `sha256=${hmac().digest("hex")}`; - const tampered = new TextEncoder().encode('{"action":"closed"}'); - assert.isFalse( - verifyWebhookSignature({ - signature: github, - secret, - headers: { "x-hub-signature-256": valid }, - body: tampered, - }), - ); - assert.isFalse( - verifyWebhookSignature({ - signature: github, - secret: "other", - headers: { "x-hub-signature-256": valid }, - body, - }), - ); - assert.isFalse(verifyWebhookSignature({ signature: github, secret, headers: {}, body })); - assert.isFalse( - verifyWebhookSignature({ - signature: github, - secret, - headers: { "x-hub-signature-256": hmac().digest("hex") }, - body, - }), - ); - }); + it.effect("rejects a tampered body, a wrong secret, a missing header and a missing prefix", () => + Effect.gen(function* () { + const valid = `sha256=${hmac().digest("hex")}`; + const tampered = new TextEncoder().encode('{"action":"closed"}'); + assert.isFalse( + yield* verifyWebhookSignature({ + signature: github, + secret, + headers: { "x-hub-signature-256": valid }, + body: tampered, + }), + ); + assert.isFalse( + yield* verifyWebhookSignature({ + signature: github, + secret: "other", + headers: { "x-hub-signature-256": valid }, + body, + }), + ); + assert.isFalse( + yield* verifyWebhookSignature({ signature: github, secret, headers: {}, body }), + ); + assert.isFalse( + yield* verifyWebhookSignature({ + signature: github, + secret, + headers: { "x-hub-signature-256": hmac().digest("hex") }, + body, + }), + ); + }).pipe(Effect.provide(PlatformNodeCrypto.layerMac)), + ); - it("accepts a base64 signature without prefix", () => { - assert.isTrue( - verifyWebhookSignature({ - signature: { header: "X-Signature", encoding: "base64", prefix: "" }, - secret, - headers: { "x-signature": hmac().digest("base64") }, - body, - }), - ); - }); + it.effect("accepts a hex signature with its prefix and a base64 signature without one", () => + Effect.gen(function* () { + assert.isTrue( + yield* verifyWebhookSignature({ + signature: github, + secret, + headers: { "x-hub-signature-256": `sha256=${hmac().digest("hex").toUpperCase()}` }, + body, + }), + ); + assert.isTrue( + yield* verifyWebhookSignature({ + signature: { header: "X-Signature", encoding: "base64", prefix: "" }, + secret, + headers: { "x-signature": hmac().digest("base64") }, + body, + }), + ); + }).pipe(Effect.provide(PlatformNodeCrypto.layerMac)), + ); }); diff --git a/apps/server/src/scheduledTasks/webhookVerification.ts b/apps/server/src/scheduledTasks/webhookVerification.ts index 292c8e97a2eee..d0c75ed9e43a9 100644 --- a/apps/server/src/scheduledTasks/webhookVerification.ts +++ b/apps/server/src/scheduledTasks/webhookVerification.ts @@ -1,32 +1,48 @@ -// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual. -import * as NodeCrypto from "node:crypto"; - import type { ScheduledTaskWebhookSignature } from "@t3tools/contracts"; +import * as Digest from "effect/crypto/Digest"; +import * as Mac from "effect/crypto/Mac"; +import * as Effect from "effect/Effect"; +import * as Base64 from "effect/encoding/Base64"; +import * as Hex from "effect/encoding/Hex"; +import * as Result from "effect/Result"; + +const utf8 = (value: string) => new TextEncoder().encode(value); -/** Constant-time string comparison that does not leak length through timing. */ -export function constantTimeEquals(a: string, b: string): boolean { - const digestA = NodeCrypto.createHash("sha256").update(a).digest(); - const digestB = NodeCrypto.createHash("sha256").update(b).digest(); - return NodeCrypto.timingSafeEqual(digestA, digestB); -} +/** Compares hashes so the comparison does not leak either string's length. */ +export const constantTimeEquals = Effect.fn("webhookVerification.constantTimeEquals")(function* ( + a: string, + b: string, +) { + const digest = yield* Digest.Digest; + const [left, right] = yield* Effect.all([ + digest.digest(Digest.sha256, utf8(a)), + digest.digest(Digest.sha256, utf8(b)), + ]); + return Mac.timingSafeEqual(left, right); +}); -export function verifyWebhookSignature(input: { - readonly signature: ScheduledTaskWebhookSignature; - readonly secret: string; - readonly headers: Readonly>; - readonly body: Uint8Array; -}): boolean { - const received = input.headers[input.signature.header.toLowerCase()]; - if (received === undefined) return false; - const value = received.trim(); - const prefix = input.signature.prefix; - if (prefix !== "" && !value.toLowerCase().startsWith(prefix.toLowerCase())) return false; - const digest = NodeCrypto.createHmac("sha256", input.secret).update(input.body).digest(); - const expected = - input.signature.encoding === "hex" ? digest.toString("hex") : digest.toString("base64"); - const candidate = value.slice(prefix.length); - return constantTimeEquals( - input.signature.encoding === "hex" ? candidate.toLowerCase() : candidate, - expected, - ); -} +export const verifyWebhookSignature = Effect.fn("webhookVerification.verifyWebhookSignature")( + function* (input: { + readonly signature: ScheduledTaskWebhookSignature; + readonly secret: string; + readonly headers: Readonly>; + readonly body: Uint8Array; + }) { + const received = input.headers[input.signature.header.toLowerCase()]; + if (received === undefined) return false; + const value = received.trim(); + const prefix = input.signature.prefix; + if (prefix !== "" && !value.toLowerCase().startsWith(prefix.toLowerCase())) return false; + const candidate = value.slice(prefix.length); + const tag = + input.signature.encoding === "hex" + ? Hex.decode(candidate.toLowerCase()) + : Base64.decode(candidate); + if (Result.isFailure(tag)) return false; + const mac = yield* Mac.Mac; + return yield* Mac.key(Mac.hmacSha256, utf8(input.secret)).pipe( + Effect.flatMap((key) => mac.verify(key, tag.success, input.body)), + Effect.orElseSucceed(() => false), + ); + }, +); diff --git a/apps/server/src/secrets/SecretRequests.test.ts b/apps/server/src/secrets/SecretRequests.test.ts index de7fb7ed5934d..d7a636613973a 100644 --- a/apps/server/src/secrets/SecretRequests.test.ts +++ b/apps/server/src/secrets/SecretRequests.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { + OrchestratorMcpRequestSecretInput, ProjectId, type OrchestrationV2ServerCommand, SecretRef, @@ -10,12 +11,14 @@ import { TurnItemId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Tracer from "effect/Tracer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -25,6 +28,8 @@ import * as SecretRequests from "./SecretRequests.ts"; const threadId = ThreadId.make("thread-orchestrator"); const turnItemId = TurnItemId.make("turn-item:secret-request:1"); const projectId = ProjectId.make("project-1"); +const decodeSecretInput = Schema.decodeUnknownEffect(OrchestratorMcpRequestSecretInput); +const decodeSecretInputExit = Schema.decodeUnknownExit(OrchestratorMcpRequestSecretInput); const withService = ( body: (input: { @@ -444,3 +449,130 @@ it.effect("a secret the card promised to keep from the agent is never written to }), ), ); + +it.effect("a pasted secret goes straight to an owner-only file, with no card", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "secret-files-test-" }); + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const pasted = "4829'$(id) 16"; + const file = yield* withService( + ({ service, stored, dispatched }) => + Effect.gen(function* () { + const file = yield* service.savePasted({ + threadId, + requestNamespace: "session-a", + requestKey: "key-1", + value: pasted, + }); + assert.equal(path.dirname(file), path.join(directory, "files")); + assert.match(path.basename(file), /^[0-9a-f]{32}$/); + assert.equal(yield* fs.readFileString(file), pasted); + if ((yield* HostProcess.Platform) !== "win32") { + assert.equal((yield* fs.stat(file)).mode & 0o777, 0o600); + assert.equal((yield* fs.stat(path.dirname(file))).mode & 0o777, 0o700); + } + // No card and no stored copy: the thread never hears of it. + assert.deepEqual(dispatched, []); + assert.deepEqual(valuesOf(stored), []); + // A retry with the same key gets the same file, unchanged even if the value differs. + assert.equal( + yield* service.savePasted({ + threadId, + requestNamespace: "session-a", + requestKey: "key-1", + value: "other", + }), + file, + ); + assert.equal(yield* fs.readFileString(file), pasted); + // Another key, or the same key from another provider session, gets its own file. + const other = yield* service.savePasted({ + threadId, + requestNamespace: "session-a", + requestKey: "key-2", + value: "x", + }); + assert.notEqual(other, file); + const otherSession = yield* service.savePasted({ + threadId, + requestNamespace: "session-b", + requestKey: "key-1", + value: "y", + }); + assert.notEqual(otherSession, file); + assert.equal(yield* fs.readFileString(file), pasted); + // Its name is not a ref to the value. + const ref = SecretRef.make(`secret-ref:${path.basename(file)}`); + const reused = yield* service.consume({ ref, projectId }).pipe(Effect.flip); + assert.equal(reused.reason, "ref_unavailable"); + return file; + }).pipe(Effect.withTracer(tracer)), + { directory }, + ); + const recorded = spans.flatMap((span) => [ + span.name, + ...Array.from(span.attributes.values(), String), + ]); + assert.include(recorded, "SecretRequests.savePasted"); + assert.isFalse(recorded.some((value) => value.includes("4829"))); + + const { mtime } = yield* fs.stat(file); + yield* TestClock.setTime(Option.getOrThrow(mtime).getTime() + SECRET_FILE_TTL_MS); + yield* withService(() => Effect.void, { directory }); + assert.isTrue(yield* fs.exists(file)); + yield* TestClock.setTime(Option.getOrThrow(mtime).getTime() + SECRET_FILE_TTL_MS + 1); + yield* withService(() => Effect.void, { directory }); + assert.isFalse(yield* fs.exists(file)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("a pasted secret keeps its exact bytes from the tool input to the file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "secret-files-test-" }); + const pasted = " pa ss\tword\t "; + const input = yield* decodeSecretInput({ + label: "Staging password", + reason: "Signs in the QA account.", + value: pasted, + }); + assert.equal(input.value, pasted); + const empty = decodeSecretInputExit({ + label: "Staging password", + reason: "Signs in the QA account.", + value: "", + }); + assert.isTrue(Exit.isFailure(empty)); + const file = yield* withService( + ({ service }) => + service.savePasted({ + threadId, + requestNamespace: "session-a", + requestKey: "key-1", + value: input.value!, + }), + { directory }, + ); + assert.equal(yield* fs.readFileString(file), pasted); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("a pasted secret is refused without a secrets directory", () => + withService(({ service }) => + Effect.gen(function* () { + const failed = yield* service + .savePasted({ threadId, requestNamespace: "session-a", requestKey: "key-1", value: "4829" }) + .pipe(Effect.flip); + assert.equal(failed.reason, "store_failed"); + }), + ), +); diff --git a/apps/server/src/secrets/SecretRequests.ts b/apps/server/src/secrets/SecretRequests.ts index e1fb4901cbdb5..4d4c2aec64414 100644 --- a/apps/server/src/secrets/SecretRequests.ts +++ b/apps/server/src/secrets/SecretRequests.ts @@ -1,7 +1,7 @@ // Answers stay in the secret store behind one-use refs; orchestration records only request status. // Consuming tools keep values out of transcripts, projections, clients and model context. // A `file` request instead moves its value to an owner-only file for the agent's own shell. -import { pipe } from "effect/Function"; +// A secret the user pasted in chat goes straight to such a file, with no card. import { CommandId, SecretRef, @@ -10,12 +10,12 @@ import { type SecretRequestAnswerInput, type ThreadId, } from "@t3tools/contracts"; -// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac. -import * as NodeCrypto from "node:crypto"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Mac from "effect/crypto/Mac"; import * as Effect from "effect/Effect"; +import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -34,14 +34,6 @@ const SECRET_REF_TTL_MS = 24 * 60 * 60 * 1000; const STORE_NAME_PATTERN = /^(secret-request-[0-9a-f]{32})\.bin$/; const FILE_NAME_PATTERN = /^[0-9a-f]{32}(\.partial)?$/; -const refFor = (salt: string, threadId: ThreadId, turnItemId: string) => - SecretRef.make( - `${SECRET_REF_PREFIX}${NodeCrypto.createHmac("sha256", salt) - .update(`${threadId}\u0000${turnItemId}`) - .digest("hex") - .slice(0, 32)}`, - ); - const StoredSecret = Schema.fromJsonString( Schema.Struct({ projectId: Schema.String, value: Schema.String, savedAt: Schema.Number }), ); @@ -65,6 +57,14 @@ export class SecretRequests extends Context.Service< readonly threadId: ThreadId; readonly turnItemId: string; }) => Effect.Effect; + /** Writes a secret the user pasted in chat to an owner-only file and returns its path. */ + readonly savePasted: (input: { + readonly threadId: ThreadId; + /** The provider session's namespace, so two sessions' keys never share a file. */ + readonly requestNamespace: string; + readonly requestKey: string; + readonly value: string; + }) => Effect.Effect; } >()("t3/secrets/SecretRequests") {} @@ -76,9 +76,19 @@ const make = Effect.gen(function* () { // Beside the store's own files so the same owner-only directory and cleanup rules apply. const filesDir = store.directory === undefined ? undefined : path.join(store.directory, "files"); - const salt = Buffer.from( - yield* store.getOrCreateRandom("secret-request-salt", 32).pipe(Effect.orDie), - ).toString("hex"); + const mac = yield* Mac.Mac; + // Keyed by the salt's hex text, as refs saved by earlier versions were. + const refKey = yield* Mac.key( + Mac.hmacSha256, + new TextEncoder().encode( + Hex.encode(yield* store.getOrCreateRandom("secret-request-salt", 32).pipe(Effect.orDie)), + ), + ).pipe(Effect.orDie); + const refFor = (threadId: ThreadId, turnItemId: string) => + mac.sign(refKey, new TextEncoder().encode(`${threadId}\u0000${turnItemId}`)).pipe( + Effect.orDie, + Effect.map((tag) => SecretRef.make(`${SECRET_REF_PREFIX}${Hex.encode(tag).slice(0, 32)}`)), + ); const answer: SecretRequests["Service"]["answer"] = Effect.fn("SecretRequests.answer")( function* (input) { @@ -113,7 +123,7 @@ const make = Effect.gen(function* () { }).pipe(Effect.orDie); yield* store .create( - storeName(refFor(salt, input.threadId, item.id)), + storeName(yield* refFor(input.threadId, item.id)), new TextEncoder().encode(encoded), ) .pipe( @@ -143,7 +153,9 @@ const make = Effect.gen(function* () { // Clear failed saves so the pending card can be answered again. Effect.tapError(() => input.answer.type === "save" - ? pipe(refFor(salt, input.threadId, item.id), storeName, removeLogged) + ? refFor(input.threadId, item.id).pipe( + Effect.flatMap((ref) => removeLogged(storeName(ref))), + ) : Effect.void, ), ); @@ -159,7 +171,7 @@ const make = Effect.gen(function* () { ); const card = recorded.turnItems.find((candidate) => candidate.id === item.id); if (card?.type === "secret_request" && card.secretStatus !== "saved") { - yield* pipe(refFor(salt, input.threadId, item.id), storeName, removeLogged); + yield* removeLogged(storeName(yield* refFor(input.threadId, item.id))); return yield* new SecretRequestError({ reason: "agent_stopped" }); } }, @@ -167,7 +179,7 @@ const make = Effect.gen(function* () { const savedRef: SecretRequests["Service"]["savedRef"] = Effect.fn("SecretRequests.savedRef")( function* (input) { - const ref = refFor(salt, input.threadId, input.turnItemId); + const ref = yield* refFor(input.threadId, input.turnItemId); const stored = yield* store.get(storeName(ref)).pipe(Effect.orElseSucceed(Option.none)); return Option.map(stored, () => ref); }, @@ -225,6 +237,23 @@ const make = Effect.gen(function* () { return value; }); + const writeSecretFile = (dir: string, file: string, value: string) => { + const partial = `${file}.partial`; + return fileSystem.makeDirectory(dir, { recursive: true, mode: 0o700 }).pipe( + Effect.andThen(fileSystem.chmod(dir, 0o700)), + Effect.andThen(fileSystem.remove(partial, { force: true })), + // `wx` never follows or replaces an existing path. + Effect.andThen( + fileSystem.writeFile(partial, new TextEncoder().encode(value), { + flag: "wx", + mode: 0o600, + }), + ), + Effect.andThen(fileSystem.rename(partial, file)), + Effect.mapError((cause) => new SecretRequestError({ reason: "store_failed", cause })), + ); + }; + const saveToFile: SecretRequests["Service"]["saveToFile"] = Effect.fn( "SecretRequests.saveToFile", )(function* (input) { @@ -244,7 +273,7 @@ const make = Effect.gen(function* () { return yield* new SecretRequestError({ reason: "not_a_file_request" }); } if (filesDir === undefined) return yield* new SecretRequestError({ reason: "store_failed" }); - const ref = refFor(salt, input.threadId, item.id); + const ref = yield* refFor(input.threadId, item.id); const file = path.join(filesDir, ref.slice(SECRET_REF_PREFIX.length)); return yield* Effect.gen(function* () { // One use: the file counts as delivered only once the stored copy is gone. A retry with the @@ -259,24 +288,29 @@ const make = Effect.gen(function* () { } // The stored value goes only after the file is complete, so a failed write can retry. const value = yield* readRef({ ref, projectId: records.thread.projectId }); - const partial = `${file}.partial`; - yield* fileSystem.makeDirectory(filesDir, { recursive: true, mode: 0o700 }).pipe( - Effect.andThen(fileSystem.chmod(filesDir, 0o700)), - Effect.andThen(fileSystem.remove(partial, { force: true })), - // `wx` never follows or replaces an existing path. - Effect.andThen( - fileSystem.writeFile(partial, new TextEncoder().encode(value), { - flag: "wx", - mode: 0o600, - }), - ), - Effect.andThen(fileSystem.rename(partial, file)), - Effect.mapError((cause) => new SecretRequestError({ reason: "store_failed", cause })), - ); + yield* writeSecretFile(filesDir, file, value); return yield* removeStored; }).pipe(consumeLock.withPermits(1)); }); + const savePasted: SecretRequests["Service"]["savePasted"] = Effect.fn( + "SecretRequests.savePasted", + )(function* (input) { + if (filesDir === undefined) return yield* new SecretRequestError({ reason: "store_failed" }); + const ref = yield* refFor( + input.threadId, + `pasted\u0000${input.requestNamespace}\u0000${input.requestKey}`, + ); + const file = path.join(filesDir, ref.slice(SECRET_REF_PREFIX.length)); + // A retry with the same clientRequestId gets the file the first call wrote, unchanged, so a + // command already holding that path never reads another value. + return yield* Effect.gen(function* () { + if (yield* fileSystem.exists(file).pipe(Effect.orElseSucceed(() => false))) return file; + yield* writeSecretFile(filesDir, file, input.value); + return file; + }).pipe(consumeLock.withPermits(1)); + }); + const sweepExpired = Effect.gen(function* () { if (store.directory === undefined) return; const now = yield* Clock.currentTimeMillis; @@ -323,7 +357,7 @@ const make = Effect.gen(function* () { Effect.forkScoped, ); - return SecretRequests.of({ answer, savedRef, consume, saveToFile }); + return SecretRequests.of({ answer, savedRef, consume, saveToFile, savePasted }); }); export const layer = Layer.effect(SecretRequests, make); diff --git a/apps/server/src/secrets/coderSecretResolver.ts b/apps/server/src/secrets/coderSecretResolver.ts index 38e62bca7ec7e..653575036b737 100644 --- a/apps/server/src/secrets/coderSecretResolver.ts +++ b/apps/server/src/secrets/coderSecretResolver.ts @@ -18,13 +18,16 @@ export class SecretResolver extends Context.Service< /** Forgets the given references, or every reference, so the next read asks the provider again. */ readonly invalidate: (references?: ReadonlyArray) => Effect.Effect; } + // @effect-diagnostics-next-line deterministicKeys:off - Coder: stands in for upstream's resolver under its key. >()("@t3tools/secret-provider-core/server/SecretResolver") {} export const layer = Layer.succeed( SecretResolver, SecretResolver.of({ - read: (references) => Effect.succeed(references.map(() => undefined)), - invalidate: () => Effect.void, + read: Effect.fn("SecretResolver.read")((references) => + Effect.succeed(references.map(() => undefined)), + ), + invalidate: Effect.fn("SecretResolver.invalidate")(() => Effect.void), }), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e5e49c44c7ac5..bec78f69525f2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -35,6 +35,7 @@ import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; +import * as AssetSigner from "./assets/AssetSigner.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; @@ -45,8 +46,10 @@ import * as ProjectionStoreV2 from "./orchestration-v2/ProjectionStore.ts"; const layerPtyAdapter = NodePtyAdapter.layer; -// Coder: the secret store also holds the one-use values of agent secret requests. +// Coder: the secret store also holds the one-use values of agent secret requests, and the asset +// signer's key. const layerServerSettings = ServerSettings.layer.pipe( + Layer.provideMerge(AssetSigner.layer), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge(SqlitePersistence.layerConfig), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index d5b08045ef7fb..088a2c106fdfa 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -2,5 +2,5 @@ import * as Context from "effect/Context"; /** Acquiring this service waits for workspace runtime initialization. */ export class CoderRuntimeStartup extends Context.Service()( - "t3/serverRuntimeStartup", + "t3/serverRuntimeStartup/CoderRuntimeStartup", ) {} diff --git a/apps/server/src/serverSettings.persistence.test.ts b/apps/server/src/serverSettings.persistence.test.ts index 33afa89ef7288..987303d11e09a 100644 --- a/apps/server/src/serverSettings.persistence.test.ts +++ b/apps/server/src/serverSettings.persistence.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; @@ -21,66 +22,69 @@ const settingsLayer = ServerSettings.layer.pipe( ), ); -it.layer(NodeServices.layer)("server settings persistence", (it) => { - it.effect("persists merge preferences and clears only the selected project's override", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* ServerSettings.ServerSettingsService; - yield* settings.start; - const projectA = ProjectId.make("project-a"); - const projectB = ProjectId.make("project-b"); - yield* settings.updateSettings({ - pullRequestMergeMethod: "squash", - projectSettingsOverrides: { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( + "server settings persistence", + (it) => { + it.effect("persists merge preferences and clears only the selected project's override", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* ServerSettings.ServerSettingsService; + yield* settings.start; + const projectA = ProjectId.make("project-a"); + const projectB = ProjectId.make("project-b"); + yield* settings.updateSettings({ + pullRequestMergeMethod: "squash", + projectSettingsOverrides: { + [projectA]: { pullRequestMergeMethod: "rebase" }, + [projectB]: { pullRequestMergeMethod: "merge" }, + }, + }); + const saved = JSON.parse(yield* fileSystem.readFileString(config.settingsPath)); + assert.strictEqual(saved.pullRequestMergeMethod, "squash"); + assert.deepStrictEqual(saved.projectSettingsOverrides, { [projectA]: { pullRequestMergeMethod: "rebase" }, [projectB]: { pullRequestMergeMethod: "merge" }, - }, - }); - const saved = JSON.parse(yield* fileSystem.readFileString(config.settingsPath)); - assert.strictEqual(saved.pullRequestMergeMethod, "squash"); - assert.deepStrictEqual(saved.projectSettingsOverrides, { - [projectA]: { pullRequestMergeMethod: "rebase" }, - [projectB]: { pullRequestMergeMethod: "merge" }, - }); - yield* settings.updateSettings({ projectSettingsOverrides: { [projectA]: null } }); - const cleared = JSON.parse(yield* fileSystem.readFileString(config.settingsPath)); - assert.strictEqual(cleared.pullRequestMergeMethod, "squash"); - assert.deepStrictEqual(cleared.projectSettingsOverrides, { - [projectB]: { pullRequestMergeMethod: "merge" }, - }); - }).pipe(Effect.provide(settingsLayer)), - ); + }); + yield* settings.updateSettings({ projectSettingsOverrides: { [projectA]: null } }); + const cleared = JSON.parse(yield* fileSystem.readFileString(config.settingsPath)); + assert.strictEqual(cleared.pullRequestMergeMethod, "squash"); + assert.deepStrictEqual(cleared.projectSettingsOverrides, { + [projectB]: { pullRequestMergeMethod: "merge" }, + }); + }).pipe(Effect.provide(settingsLayer)), + ); - it.effect("writes and clears a non-default automatic Git fetch interval", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* ServerSettings.ServerSettingsService; - yield* settings.start; + it.effect("writes and clears a non-default automatic Git fetch interval", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* ServerSettings.ServerSettingsService; + yield* settings.start; - const updated = yield* settings.updateSettings({ - automaticGitFetchInterval: Duration.seconds(45), - }); + const updated = yield* settings.updateSettings({ + automaticGitFetchInterval: Duration.seconds(45), + }); - assert.strictEqual(Duration.toMillis(updated.automaticGitFetchInterval), 45_000); - const saved = JSON.parse(yield* fileSystem.readFileString(config.settingsPath)); - assert.strictEqual(saved.automaticGitFetchInterval, 45_000); - assert.deepStrictEqual(saved.backgroundActivity.overrides, { - automaticGitFetchInterval: 45_000, - }); + assert.strictEqual(Duration.toMillis(updated.automaticGitFetchInterval), 45_000); + const saved = JSON.parse(yield* fileSystem.readFileString(config.settingsPath)); + assert.strictEqual(saved.automaticGitFetchInterval, 45_000); + assert.deepStrictEqual(saved.backgroundActivity.overrides, { + automaticGitFetchInterval: 45_000, + }); - const reset = yield* settings.updateSettings({ - automaticGitFetchInterval: DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, - }); + const reset = yield* settings.updateSettings({ + automaticGitFetchInterval: DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, + }); - assert.strictEqual( - Duration.toMillis(reset.automaticGitFetchInterval), - Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL), - ); - const reverted = JSON.parse(yield* fileSystem.readFileString(config.settingsPath)); - assert.strictEqual(reverted.automaticGitFetchInterval, undefined); - assert.strictEqual(reverted.backgroundActivity, undefined); - }).pipe(Effect.provide(settingsLayer)), - ); -}); + assert.strictEqual( + Duration.toMillis(reset.automaticGitFetchInterval), + Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL), + ); + const reverted = JSON.parse(yield* fileSystem.readFileString(config.settingsPath)); + assert.strictEqual(reverted.automaticGitFetchInterval, undefined); + assert.strictEqual(reverted.backgroundActivity, undefined); + }).pipe(Effect.provide(settingsLayer)), + ); + }, +); diff --git a/apps/server/src/serverSettings.redaction.test.ts b/apps/server/src/serverSettings.redaction.test.ts index e70c6b4b92263..c2128f3a722e4 100644 --- a/apps/server/src/serverSettings.redaction.test.ts +++ b/apps/server/src/serverSettings.redaction.test.ts @@ -10,23 +10,26 @@ import { redactServerSettingsForClient } from "./serverSettings.ts"; describe("redactServerSettingsForClient", () => { it("blanks sensitive provider environment values", () => { const instanceId = ProviderInstanceId.make("claude-workspace"); - const redacted = redactServerSettingsForClient({ - ...DEFAULT_SERVER_SETTINGS, - providerInstances: { - [instanceId]: { - driver: ProviderDriverKind.make("claude"), - environment: [ - { name: "ANTHROPIC_API_KEY", value: "workspace-secret", sensitive: true }, - { - name: "ANTHROPIC_BASE_URL", - value: "https://example.test", - sensitive: false, - valueRedacted: true, - }, - ], + const redacted = redactServerSettingsForClient( + { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("claude"), + environment: [ + { name: "ANTHROPIC_API_KEY", value: "workspace-secret", sensitive: true }, + { + name: "ANTHROPIC_BASE_URL", + value: "https://example.test", + sensitive: false, + valueRedacted: true, + }, + ], + }, }, }, - }); + { secretReferences: true }, + ); expect(redacted.providerInstances[instanceId]?.environment).toEqual([ { diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index e81da3b6386ff..68e3a02e49f2d 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, @@ -95,7 +96,7 @@ const recordProviderUsage = (provider: string, instanceId: string | null = provi `; }); -it.layer(NodeServices.layer)("server settings", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings", (it) => { it.effect("serializes concurrent model preference edits without cancelling duplicate adds", () => Effect.gen(function* () { const service = yield* ServerSettingsModule.ServerSettingsService; @@ -271,7 +272,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }); assert.deepStrictEqual( - ServerSettingsModule.redactServerSettingsForClient(settings).secretProviders, + ServerSettingsModule.redactServerSettingsForClient(settings, { secretReferences: true }) + .secretProviders, // Coder: no secret provider kind is loaded, so 1Password's config stays on the server too. { personal: { kind: "one_password", label: "Personal" }, @@ -1309,7 +1311,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { ], }); const values = ["config-secret", "env-secret", "quota-secret", "expo-secret"]; - const client = ServerSettingsModule.redactServerSettingsForClient(saved); + const client = ServerSettingsModule.redactServerSettingsForClient(saved, { + secretReferences: true, + }); const disk = yield* fs.readFileString(config.settingsPath); for (const value of values) { assert.include(JSON.stringify(saved), value); @@ -1341,7 +1345,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { instance: { ...client.providerInstances[instanceId]!, config: { apiKey } }, }); assert.propertyVal(changed.providerInstances[instanceId]?.config, "apiKey", apiKey); - const redacted = ServerSettingsModule.redactServerSettingsForClient(changed); + const redacted = ServerSettingsModule.redactServerSettingsForClient(changed, { + secretReferences: true, + }); assert.propertyVal( redacted.providerInstances[instanceId]?.config, "apiKey", @@ -1379,9 +1385,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); assert.isTrue(Option.isNone(yield* store.get(secretNames[1]!))); assert.deepEqual( - ServerSettingsModule.redactServerSettingsForClient(referenced).providerInstances[ - instanceId - ]?.environment, + ServerSettingsModule.redactServerSettingsForClient(referenced, { secretReferences: true }) + .providerInstances[instanceId]?.environment, [{ name: "API_TOKEN", value: secret, sensitive: true }], ); yield* service.updateSettings({ diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 3bd0232f7e276..f57df2e5f704d 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -17,6 +17,8 @@ import { ServerSettingsError, type ServerSettingsPatch, globalIdleCompaction, + isSecretReference, + type SecretReference, withIdleCompaction, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; @@ -59,7 +61,12 @@ import { sourceControlHostSecretName, } from "./sourceControl/sourceControlHostSecrets.ts"; -import { mapSettingsSecrets, SECRET_REDACTED, type SettingsSecret } from "./settingsSecrets.ts"; +import { + mapSettingsSecrets, + mapSettingsSecretValues, + SECRET_REDACTED, + type SettingsSecret, +} from "./settingsSecrets.ts"; export { resolveReviewGuideModelSelection, @@ -130,13 +137,35 @@ const normalizeServerSettings = ( ), ); -export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { +export function redactServerSettingsForClient( + settings: ServerSettings, + client: { readonly secretReferences: boolean }, +): ServerSettings { const redacted = mapSettingsSecrets(settings, (secret) => secret.sensitive ? { value: "", redacted: secret.redacted || secret.value.length > 0 } : { value: secret.value, redacted: false }, ); - return { ...redacted, secretProviders: withoutUnknownSecretProviderConfigs(redacted) }; + // Older clients decode secret fields as strings and fail on a reference, so they see a saved secret. + const shaped = client.secretReferences + ? redacted + : mapSettingsSecretValues(redacted, (value) => + isSecretReference(value) ? SECRET_REDACTED : value, + ); + return { ...shaped, secretProviders: withoutUnknownSecretProviderConfigs(shaped) }; +} + +/** A client shown a reference as a saved secret sends that marker back; it keeps the reference. */ +function withEchoedSecretReferences(current: ServerSettings, next: ServerSettings): ServerSettings { + const references = new Map(); + mapSettingsSecretValues(current, (value, secretName) => { + if (isSecretReference(value)) references.set(secretName, value); + return value; + }); + if (references.size === 0) return next; + return mapSettingsSecretValues(next, (value, secretName) => + value === SECRET_REDACTED ? (references.get(secretName) ?? value) : value, + ); } // A newer kind's config may hold credentials this build cannot recognize, so it never leaves the server. @@ -946,7 +975,7 @@ const make = Effect.gen(function* () { }); const changes: SecretChange[] = []; const nextSecretNames = new Set(); - const settings = mapSettingsSecrets(next, (secret) => { + const settings = mapSettingsSecrets(withEchoedSecretReferences(current, next), (secret) => { const metadata = { secretName: secret.secretName, ...(secret.providerInstanceId !== undefined diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 16450d180d759..9e4c13d1f50dd 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -3,7 +3,9 @@ import { ClaudeSettings, CodexSettings, EasDeviceHostConfig, + type ProviderInstanceConfig, type ServerSettings, + type SettingsSecretValue, SimlockDeviceHostConfig, UsageLimitSourceConfig, isSecretReference, @@ -47,16 +49,19 @@ export interface SettingsSecret { } type SecretValue = Pick; +type SecretSlotName = (field: string, serverHost: string | null) => string; +type SecretFieldsMapper = ( + fields: Readonly>, + secretFields: ReadonlyArray, + name: SecretSlotName, +) => Record; +type ProviderEnvironment = NonNullable; export function mapSettingsSecrets( settings: ServerSettings, map: (secret: SettingsSecret) => SecretValue, ): ServerSettings { - const mapFields = ( - fields: Readonly>, - secretFields: ReadonlyArray, - name: (field: string, serverHost: string | null) => string, - ) => { + const mapFields: SecretFieldsMapper = (fields, secretFields, name) => { const next = { ...fields }; for (const field of secretFields) { if (!(field in next) || isSecretReference(next[field])) continue; @@ -77,6 +82,70 @@ export function mapSettingsSecrets( return next; }; + return mapSecretFieldGroups(settings, mapFields, (instanceId, environment) => + environment.map((variable) => { + const { valueRedacted: _omit, ...rest } = variable; + // A secret reference is not itself secret; storage sees the value cleared. + const reference = typeof variable.value === "string" ? undefined : variable.value; + const result = map({ + secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`, + kind: "environment", + value: typeof variable.value === "string" ? variable.value : "", + redacted: reference === undefined && variable.valueRedacted === true, + sensitive: variable.sensitive, + providerInstanceId: instanceId, + environmentVariable: variable.name, + }); + if (reference !== undefined) return { ...rest, value: reference }; + return { + ...rest, + value: result.value, + ...(result.redacted ? { valueRedacted: true } : {}), + }; + }), + ); +} + +/** + * Maps each value a secret field holds, literal or reference, with the secret name of its slot. + * Provider environment variables are left as they are. + */ +export function mapSettingsSecretValues( + settings: ServerSettings, + map: (value: SettingsSecretValue, secretName: string) => SettingsSecretValue, +): ServerSettings { + const mapValue = (value: unknown, secretName: string) => + typeof value === "string" || isSecretReference(value) ? map(value, secretName) : value; + return mapSecretFieldGroups(settings, (fields, secretFields, name) => { + const next = { ...fields }; + for (const field of secretFields) { + if (!(field in next)) continue; + const value = next[field]; + next[field] = + value !== null && + typeof value === "object" && + !Array.isArray(value) && + !isSecretReference(value) + ? Object.fromEntries( + Object.entries(value).map(([serverHost, entry]) => [ + serverHost, + mapValue(entry, name(field, serverHost.trim().toLowerCase())), + ]), + ) + : mapValue(value, name(field, null)); + } + return next; + }); +} + +function mapSecretFieldGroups( + settings: ServerSettings, + mapFields: SecretFieldsMapper, + mapEnvironment: (instanceId: string, environment: ProviderEnvironment) => ProviderEnvironment = ( + _instanceId, + environment, + ) => environment, +): ServerSettings { const providerInstances = Object.fromEntries( Object.entries(settings.providerInstances).map(([instanceId, instance]) => { const config = instance.config; @@ -96,28 +165,7 @@ export function mapSettingsSecrets( } : {}), ...(instance.environment - ? { - environment: instance.environment.map((variable) => { - const { valueRedacted: _omit, ...rest } = variable; - // A secret reference is not itself secret; storage sees the value cleared. - const reference = typeof variable.value === "string" ? undefined : variable.value; - const result = map({ - secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`, - kind: "environment", - value: typeof variable.value === "string" ? variable.value : "", - redacted: reference === undefined && variable.valueRedacted === true, - sensitive: variable.sensitive, - providerInstanceId: instanceId, - environmentVariable: variable.name, - }); - if (reference !== undefined) return { ...rest, value: reference }; - return { - ...rest, - value: result.value, - ...(result.redacted ? { valueRedacted: true } : {}), - }; - }), - } + ? { environment: mapEnvironment(instanceId, instance.environment) } : {}), }, ]; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index c9f4b3b9269d2..7b9cfc2113596 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -199,6 +199,23 @@ it.effect("leaves a remote no integration claims unknown, with its server's addr }), ); +it.effect.each([ + // Coder: the registry holds only GitLab, so upstream's GitHub and Bitbucket rows are cut. + ["gitlab.com:team/project.git", "gitlab"], + ["code.example.test:team/project.git", "unknown"], +] as const)( + "reads the host of an SSH remote that leaves its user to ssh config: %s", + ([url, expected]) => + Effect.gen(function* () { + const registry = yield* makeRegistry({ remotes: [{ name: "origin", url }] }); + + const handle = yield* registry.resolveHandle({ cwd: "/repo" }); + + assert.strictEqual(handle.provider.kind, kind(expected)); + assert.strictEqual(handle.context?.remoteUrl, url); + }), +); + it.effect("refines the caller-selected remote instead of choosing another configured remote", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index fe805c85881d5..c785b4cf33617 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -1,5 +1,4 @@ -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -22,6 +21,7 @@ import * as SourceControlRepositoryService from "./SourceControlRepositoryServic const TestLayer = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-repository-test-" }).pipe( Layer.provide(NodeServices.layer), ), @@ -30,12 +30,15 @@ const TestLayer = Layer.mergeAll( }), Layer.mock(GitVcsDriver.GitVcsDriver)({ execute: (input) => - Effect.sync(() => { - const directoryName = input.args.at(-1)!; - const destination = NodePath.join(input.cwd, directoryName); - NodeFS.mkdirSync(destination, { recursive: true }); - NodeFS.writeFileSync(NodePath.join(destination, "partial-clone"), "partial\n"); + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const destination = path.join(input.cwd, input.args.at(-1)!); + yield* fileSystem.makeDirectory(destination, { recursive: true }); + yield* fileSystem.writeFileString(path.join(destination, "partial-clone"), "partial\n"); }).pipe( + Effect.orDie, + Effect.provide(NodeServices.layer), Effect.andThen( Effect.fail( new GitCommandError({ @@ -86,27 +89,27 @@ for (const [repository, protocol, expectedUrl] of [ const calls: Array> = []; const service = yield* SourceControlRepositoryService.make.pipe( Effect.provide( - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => undefined, - get: () => Effect.succeed(provider), - }), - ), - Effect.provide( - Layer.mock(GitVcsDriver.GitVcsDriver)({ - execute: (input) => - Effect.gen(function* () { - // Upstream creates missing parent folders before cloning. - assert.isTrue(yield* fileSystem.exists(input.cwd).pipe(Effect.orDie)); - calls.push(input.args); - return { - stdout: "", - stderr: "", - exitCode: ChildProcessSpawner.ExitCode(0), - stdoutTruncated: false, - stderrTruncated: false, - }; - }), - }), + Layer.mergeAll( + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => undefined, + get: () => Effect.succeed(provider), + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({ + execute: (input) => + Effect.gen(function* () { + // Upstream creates missing parent folders before cloning. + assert.isTrue(yield* fileSystem.exists(input.cwd).pipe(Effect.orDie)); + calls.push(input.args); + return { + stdout: "", + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(0), + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ), ), ); const result = yield* service.cloneRepository({ @@ -156,41 +159,41 @@ for (const remoteUrl of [ function makePublishService(reason?: GitCommandError["reason"]) { return SourceControlRepositoryService.make.pipe( Effect.provide( - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => undefined, - get: () => - SourceControlProvider.SourceControlProvider.pipe( - Effect.provide( - Layer.mock(SourceControlProvider.SourceControlProvider)({ - kind: SourceControlProviderKind.make("gitlab"), - createRepository: () => Effect.succeed(cloneUrls), - }), + Layer.mergeAll( + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => undefined, + get: () => + SourceControlProvider.SourceControlProvider.pipe( + Effect.provide( + Layer.mock(SourceControlProvider.SourceControlProvider)({ + kind: SourceControlProviderKind.make("gitlab"), + createRepository: () => Effect.succeed(cloneUrls), + }), + ), ), - ), - }), - ), - Effect.provide( - Layer.mock(GitVcsDriver.GitVcsDriver)({ - execute: () => - Effect.succeed({ - stdout: "", - stderr: "", - exitCode: ChildProcessSpawner.ExitCode(0), - stdoutTruncated: false, - stderrTruncated: false, - }), - ensureRemote: () => Effect.succeed("origin"), - pushCurrentBranch: (cwd: string) => - Effect.fail( - new GitCommandError({ - operation: "GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", - command: "git", - cwd, - ...(reason === undefined ? {} : { reason }), - detail: "Git command exited with a non-zero status.", + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({ + execute: () => + Effect.succeed({ + stdout: "", + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(0), + stdoutTruncated: false, + stderrTruncated: false, }), - ), - }), + ensureRemote: () => Effect.succeed("origin"), + pushCurrentBranch: (cwd: string) => + Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", + command: "git", + cwd, + ...(reason === undefined ? {} : { reason }), + detail: "Git command exited with a non-zero status.", + }), + ), + }), + ), ), ); } @@ -217,6 +220,80 @@ it.effect("explains a rejected SSH push after creating the repository", () => }).pipe(Effect.provide(TestLayer)), ); +// Coder: upstream's HTTPS publish fallback (#30142) on GitLab, built like the publish tests above. +// regression: an auto-selected SSH remote kept failing every later fetch after a refused push +it.effect("switches an auto-selected remote to HTTPS when the SSH push is refused", () => { + const gitArgs: Array> = []; + let pushAttempts = 0; + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => undefined, + get: () => + SourceControlProvider.SourceControlProvider.pipe( + Effect.provide( + Layer.mock(SourceControlProvider.SourceControlProvider)({ + kind: SourceControlProviderKind.make("gitlab"), + createRepository: () => Effect.succeed(cloneUrls), + }), + ), + ), + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({ + execute: (input) => + Effect.sync(() => { + gitArgs.push(input.args); + return { + stdout: "", + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(0), + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + ensureRemote: () => Effect.succeed("origin"), + pushCurrentBranch: (cwd: string) => { + pushAttempts += 1; + return pushAttempts === 1 + ? Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", + command: "git", + cwd, + reason: "authentication_failed", + detail: "Git command exited with a non-zero status.", + }), + ) + : Effect.succeed({ + status: "pushed" as const, + branch: "main", + upstreamBranch: "origin/main", + setUpstream: true, + }); + }, + }), + ), + ), + ); + const result = yield* service.publishRepository({ + cwd: "/workspace", + provider: SourceControlProviderKind.make("gitlab"), + repository: cloneUrls.nameWithOwner, + visibility: "private", + }); + + assert.strictEqual(result.status, "pushed"); + assert.strictEqual(result.remoteUrl, cloneUrls.url); + assert.strictEqual(pushAttempts, 2); + assert.deepStrictEqual(gitArgs, [ + ["rev-parse", "--verify", "HEAD"], + ["remote", "set-url", "origin", cloneUrls.url], + ]); + }).pipe(Effect.provide(TestLayer)); +}); + // regression: #19219 publishing did not explain why the first push failed it.effect("says the repository exists when an unclassified publish push fails", () => Effect.gen(function* () { diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 2acc02b2d0868..be3ca71d54c41 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -80,7 +80,8 @@ const CLONE_ENV = { // Force piped progress immediately; refuse credential prompts that would hang without a tty. GIT_PROGRESS_DELAY: "0", GIT_TERMINAL_PROMPT: "0", - LC_ALL: "C", + // English progress without overriding LC_CTYPE, which a global post-checkout hook inherits. + LANGUAGE: "C", } satisfies NodeJS.ProcessEnv; function mapRepositoryError(operation: string, provider: SourceControlProviderKind) { @@ -500,7 +501,7 @@ export const make = Effect.gen(function* () { args: ["rev-parse", "--verify", "HEAD"], }) .pipe( - Effect.map(() => true), + Effect.as(true), Effect.orElseSucceed(() => false), ); if (!hasCommits) { @@ -514,28 +515,50 @@ export const make = Effect.gen(function* () { }; } - const pushResult = yield* git.pushCurrentBranch(input.cwd, null, { remoteName }).pipe( - Effect.mapError( - (cause) => - new SourceControlRepositoryError({ - operation: "publishRepository", - provider: providerKind, - detail: publishPushFailureDetail({ - urls, - remoteName, - remoteUrl, - reason: cause.reason, + const push = (pushUrl: string) => + git.pushCurrentBranch(input.cwd, null, { remoteName }).pipe( + Effect.map((result) => ({ branch: result.branch, remoteUrl: pushUrl })), + Effect.mapError( + (cause) => + new SourceControlRepositoryError({ + operation: "publishRepository", + provider: providerKind, + detail: publishPushFailureDetail({ + urls, + remoteName, + remoteUrl: pushUrl, + reason: cause.reason, + }), + cause, }), - cause, - }), + ), + ); + + // Like clone, an auto-selected SSH remote switches to HTTPS when SSH is refused; + // otherwise the remote keeps failing every later fetch and push. + const canSwitchToHttps = input.protocol !== "ssh" && remoteUrl === urls.sshUrl; + const pushed = yield* push(remoteUrl).pipe( + Effect.catchIf( + (error) => canSwitchToHttps && isSshRejection(error), + (sshError) => + git + .execute({ + operation: "SourceControlRepositoryService.publishRepository.switchToHttps", + cwd: input.cwd, + args: ["remote", "set-url", remoteName, urls.url], + }) + .pipe( + Effect.mapError(() => sshError), + Effect.andThen(push(urls.url)), + ), ), ); return { repository: toRepositoryInfo(providerKind, urls), remoteName, - remoteUrl, - branch: pushResult.branch, + remoteUrl: pushed.remoteUrl, + branch: pushed.branch, status: "pushed" as const, }; }, diff --git a/apps/server/src/sourceControl/sourceControlDefinitions.test.ts b/apps/server/src/sourceControl/sourceControlDefinitions.test.ts index e21efd8173a73..36996bdf3f370 100644 --- a/apps/server/src/sourceControl/sourceControlDefinitions.test.ts +++ b/apps/server/src/sourceControl/sourceControlDefinitions.test.ts @@ -10,3 +10,15 @@ it("runs a driver for every host the server and clients read, in their order", ( sourceControlDefinitions.definitions, ); }); + +it.each([ + "C:/repos/project", + "C:\\repos\\project", + "/srv/git/repo.git", + "./team:group/repo", + "file:///srv/git/repo.git", + "hg::team/repo", + "codecommit::us-east-1://repo", +])("finds no host in %s, which git does not read as SSH", (remoteUrl) => { + expect(sourceControlDefinitions.recognizeRemote(remoteUrl)).toBeNull(); +}); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index aaf6476b376dc..b7ea4d2e8b6bd 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -43,7 +43,16 @@ export function writeSecretFieldValues( ): unknown { if (typeof original === "string") return values[0]?.[1] ?? ""; if (isSecretReference(original)) return original; - return Object.fromEntries(values.map(([serverHost, value]) => [serverHost ?? "", value])); + const sources = + original !== null && typeof original === "object" && !Array.isArray(original) + ? Object.entries(original).flatMap(([serverHost, entry]) => + isSecretReference(entry) ? [[serverHost.trim().toLowerCase(), entry] as const] : [], + ) + : []; + return Object.fromEntries([ + ...sources, + ...values.map(([serverHost, value]) => [serverHost ?? "", value] as const), + ]); } // Coder: upstream resolves secret provider references in host credentials here (#27746); secret diff --git a/apps/server/src/storageCleanup.coder.test.ts b/apps/server/src/storageCleanup.coder.test.ts index 29f208e940e81..3046653486658 100644 --- a/apps/server/src/storageCleanup.coder.test.ts +++ b/apps/server/src/storageCleanup.coder.test.ts @@ -1,6 +1,7 @@ // Coder: artifact retention and worktree safety for the workspace helper's storage cleanup. -import * as NodeFS from "node:fs/promises"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - fixture paths are joined with Node's path. import * as NodePath from "node:path"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as Clock from "effect/Clock"; @@ -90,7 +91,7 @@ const base = Git.layer.pipe( ), ), Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const runCleanup = (snapshot: Snapshot, settings?: Parameters[0]) => @@ -127,7 +128,7 @@ it.effect( const config = yield* Config.ServerConfig; const fs = yield* FileSystem.FileSystem; const now = yield* Clock.currentTimeMillis; - const old = new Date(now - 10 * 86_400_000); + const old = DateTime.toDateUtc(DateTime.makeUnsafe(now - 10 * 86_400_000)); yield* fs.makeDirectory(config.screenshotArtifactsDir, { recursive: true }); yield* fs.makeDirectory(config.attachmentsDir, { recursive: true }); yield* fs.makeDirectory(config.logsDir, { recursive: true }); @@ -141,8 +142,8 @@ it.effect( }; for (const [name, path] of Object.entries(paths)) { yield* fs.writeFileString(path, name); - const time = name === "recent" ? new Date(now) : old; - yield* Effect.promise(() => NodeFS.utimes(path, time, time)); + const time = name === "recent" ? DateTime.toDateUtc(DateTime.makeUnsafe(now)) : old; + yield* fs.utimes(path, time, time); } const link = NodePath.join(config.screenshotArtifactsDir, "linked.png"); yield* fs.symlink(paths.source, link); diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 24ecf7efb03ea..c593a46cc1af2 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Context from "effect/Context"; @@ -588,7 +589,7 @@ const cleanupConfigLayer = Layer.unwrap( ); const cleanupTestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(cleanupConfigLayer), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const runCleanupTest = ( effect: Effect.Effect< @@ -785,6 +786,162 @@ it.live("preserves late tracked edits when submodules were never initialized", ( ); describe("subagent worktree ownership", () => { + it.effect.each([ + "child", + "grandchild", + "immediate", + "active-parent", + "recent-settle", + "settle-grace", + "recent-child-activity", + "running-child", + "pending-child", + "running-parent", + "pending-parent", + "unsettled-child", + "pinned-child", + "fork", + "missing-parent", + "cycle", + ] as const)("checks isolated subagent settlement (%s)", (scenario) => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeSettledAfterDays: scenario === "immediate" ? 0 : 8, + }); + const root = shell({ + settledAt: + scenario === "active-parent" + ? null + : scenario === "recent-settle" || scenario === "settle-grace" + ? at(-30_000) + : scenario === "immediate" + ? at(-120_000) + : at(-20 * DAY_MS), + settledOverride: scenario === "active-parent" ? null : "settled", + updatedAt: scenario === "settle-grace" ? at(-30_000) : at(-10 * DAY_MS), + }); + const child = shell({ + id: ThreadId.make("child"), + branch: "feature", + worktreePath: fixture.worktree, + lineage: { + parentThreadId: + scenario === "missing-parent" + ? ThreadId.make("missing") + : scenario === "cycle" + ? ThreadId.make("child") + : ["grandchild", "running-parent", "pending-parent"].includes(scenario) + ? ThreadId.make("middle") + : root.id, + rootThreadId: root.id, + relationshipToParent: scenario === "fork" ? "fork" : "subagent", + }, + status: scenario === "running-child" ? "running" : "completed", + pendingBackgroundTasks: + scenario === "pending-child" ? [{ taskId: "task", kind: "command" }] : [], + latestRunCompletedAt: scenario === "recent-child-activity" ? at(-2 * DAY_MS) : null, + settledOverride: scenario === "unsettled-child" ? "active" : null, + pinnedAt: scenario === "pinned-child" ? at(-DAY_MS) : null, + }); + const middle = shell({ + id: ThreadId.make("middle"), + status: scenario === "running-parent" ? "running" : "idle", + pendingBackgroundTasks: + scenario === "pending-parent" ? [{ taskId: "task", kind: "command" }] : [], + lineage: { + parentThreadId: root.id, + rootThreadId: root.id, + relationshipToParent: "subagent", + }, + }); + fixture.setThreads([child, middle, root]); + const report = yield* fixture.service.runNow; + const removed = ["child", "grandchild", "immediate"].includes(scenario); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(!removed); + expect(report.entries[0]).toMatchObject({ + threadId: child.id, + outcome: removed ? "removed" : "kept", + reason: removed + ? scenario === "immediate" + ? "Settled for 0 days" + : "Settled for 20 days" + : scenario === "settle-grace" + ? "Settled moments ago" + : scenario === "recent-settle" + ? "Settled for 0 of 8 days" + : scenario === "recent-child-activity" + ? "Settled for 2 of 8 days" + : scenario === "running-child" || scenario === "pending-child" + ? "Thread is running or has pending work" + : "Not settled", + }); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.live.each([ + "parent-unsettled", + "parent-resettled", + "child-unsettled", + "intermediate-running", + "intermediate-pending", + ] as const)("cancels inherited settlement during measurement (%s)", (change) => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* pausedMeasurementFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeSettledAfterDays: 0, + }); + const root = shell({ settledAt: at(-20 * DAY_MS), settledOverride: "settled" }); + const middle = shell({ + id: ThreadId.make("middle"), + lineage: { + parentThreadId: root.id, + rootThreadId: root.id, + relationshipToParent: "subagent", + }, + }); + const child = shell({ + id: ThreadId.make("child"), + branch: "feature", + worktreePath: fixture.worktree, + lineage: { + parentThreadId: middle.id, + rootThreadId: root.id, + relationshipToParent: "subagent", + }, + }); + fixture.setThreads([child, middle, root]); + const run = yield* fixture.service.runNow.pipe(Effect.forkChild); + yield* Deferred.await(fixture.measuring); + fixture.setThreads([ + { ...child, settledOverride: change === "child-unsettled" ? "active" : null }, + { + ...middle, + status: change === "intermediate-running" ? "running" : "idle", + pendingBackgroundTasks: + change === "intermediate-pending" ? [{ taskId: "task", kind: "command" }] : [], + }, + change === "child-unsettled" || change.startsWith("intermediate-") + ? root + : { + ...root, + settledAt: change === "parent-resettled" ? at(0) : null, + settledOverride: change === "parent-resettled" ? "settled" : "active", + updatedAt: at(0), + }, + ]); + yield* Deferred.succeed(fixture.resume, undefined); + expect((yield* Fiber.join(run)).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Parent settlement changed since check", + }); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); + }), + ), + ); + it.effect.each([ "idle", "grandchild", diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 7b41505b4e5d2..0bb5ddf579377 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -14,6 +14,7 @@ import type { WorktreeCleanupRules, StorageCleanupReport, StorageCleanupReportEntry, + ThreadId, } from "@t3tools/contracts"; import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; @@ -66,6 +67,15 @@ const decodePnpmModulesManifest = Schema.decodeUnknownEffect( }), ); +class PnpmModulesManifestParseError extends Schema.TaggedError()( + "PnpmModulesManifestParseError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not parse the pnpm modules manifest."; + } +} + const DAY_MS = 86_400_000; // Give settle scripts time to open terminals that protect the worktree. const SETTLE_SCRIPT_GRACE_MS = 60_000; @@ -124,9 +134,16 @@ export function storageCleanupThreadIdle( thread: OrchestrationV2ThreadShell, now: number, settings: LimitRecoverySettings, +): boolean { + return thread.worktreePath !== null && threadIdle(thread, now, settings); +} + +function threadIdle( + thread: OrchestrationV2ThreadShell, + now: number, + settings: LimitRecoverySettings, ): boolean { return ( - thread.worktreePath !== null && thread.activeRunId === null && (thread.status === "idle" || thread.status === "completed" || @@ -213,6 +230,36 @@ function worktreeOwner, + now: number, + settings: LimitRecoverySettings, +) { + const chain = [thread]; + let current = thread; + while (current.settledAt === null) { + if ( + current.lineage.relationshipToParent !== "subagent" || + current.settledOverride === "active" || + current.pinnedAt != null + ) + return []; + const parent = byId.get(current.lineage.parentThreadId!); + if ( + parent === undefined || + chain.some((member) => member.id === parent.id) || + parent.projectId !== thread.projectId || + parent.lineage.rootThreadId !== thread.lineage.rootThreadId || + !threadIdle(parent, now, settings) + ) + return []; + chain.push(parent); + current = parent; + } + return chain; +} + /** Squash/rebase merges require the merged PR to name this exact head commit. */ export function storageCleanupPullRequestMerged( pullRequest: Pick< @@ -303,7 +350,12 @@ const make = Effect.gen(function* () { function* (root: string) { const nodeModules = path.join(root, "node_modules"); const text = yield* fs.readFileString(path.join(nodeModules, ".modules.yaml")); - const manifest = yield* decodePnpmModulesManifest(yield* Effect.try(() => parseYaml(text))); + const manifest = yield* decodePnpmModulesManifest( + yield* Effect.try({ + try: () => parseYaml(text), + catch: (cause) => new PnpmModulesManifestParseError({ cause }), + }), + ); // An agent can write this manifest, and statting a network path makes // Windows sign in to its host with the user's NTLM credentials. if (isNetworkPath(manifest.storeDir) || isNetworkPath(manifest.virtualStoreDir ?? "")) { @@ -775,6 +827,7 @@ const make = Effect.gen(function* () { ), )).filter((thread) => thread.worktreePath !== null && thread.branch !== null); const snapshot = yield* readThreads(); + const threadsById = new Map(snapshot.threads.map((thread) => [thread.id, thread])); const refreshedDefaultRefs = new Map>(); const defaultBranches = new Map(); const groups = Map.groupBy( @@ -808,6 +861,10 @@ const make = Effect.gen(function* () { const group = groups.get(worktreePath) ?? []; const activityAt = Math.max(...group.map(storageCleanupActivityAt)); const deleted = "workspaceRoot" in thread; + const settlementChain = deleted + ? [] + : worktreeSettlementChain(thread, threadsById, now, serverSettings); + const settledThread = settlementChain.at(-1); const members = deleted ? [thread, ...group] : group; const project = deleted ? { workspaceRoot: thread.workspaceRoot } @@ -862,7 +919,7 @@ const make = Effect.gen(function* () { const changes = yield* localChangesReason(worktreePath, settings); if (changes !== null) return keep(changes); if ( - group.some( + [...group, ...settlementChain.slice(1)].some( (member) => member.settledAt !== null && now - Math.max(DateTime.toEpochMillis(member.updatedAt), activityAt) < @@ -876,9 +933,9 @@ const make = Effect.gen(function* () { settings.worktreeAfterDays !== null && activityAt < now - settings.worktreeAfterDays * DAY_MS; const settledFor = - deleted || thread.settledAt === null + settledThread?.settledAt == null ? null - : now - Math.max(DateTime.toEpochMillis(thread.settledAt), activityAt); + : now - Math.max(DateTime.toEpochMillis(settledThread.settledAt), activityAt); const settledDays = settledFor === null ? null : Math.floor(settledFor / DAY_MS); const settled = settledFor !== null && @@ -1045,6 +1102,27 @@ const make = Effect.gen(function* () { }) ) return waitForStop("thread activity or shared worktree changed since check"); + if (settlementChain.length > 1) { + const ancestors = yield* Effect.forEach(settlementChain.slice(1), (ancestor) => + projections.getThreadShell(ancestor.id), + ); + const latestSettlement = worktreeSettlementChain( + latestGroup.find((shell) => shell.id === thread.id)!, + new Map( + [...latestGroup, ...ancestors.filter((ancestor) => ancestor !== null)].map( + (ancestor) => [ancestor.id, ancestor], + ), + ), + now, + serverSettings, + ).at(-1); + if ( + latestSettlement?.id !== settledThread?.id || + !Equal.equals(latestSettlement?.settledAt, settledThread?.settledAt) || + !Equal.equals(latestSettlement?.updatedAt, settledThread?.updatedAt) + ) + return "parent settlement changed since check"; + } // Sessions can outlive their run and can be shared across app threads. const sessionRows = yield* sql<{ payload_json: string }>` SELECT payload_json FROM orchestration_v2_projection_provider_sessions diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 3cbe08b384245..31b520b12afc1 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { @@ -399,7 +400,11 @@ it("bounds long partial lines and joins surrogate pairs across chunk boundaries" }); it.layer( - Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + ProcessRunner.layer.pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ), { excludeTestServices: true }, )("TerminalManager", (it) => { it.effect("spawns lazily and reuses running terminal per thread", () => @@ -1248,7 +1253,8 @@ it.layer( const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]!; - const before = "café\t界🙂\u0007\u009c\u0085"; + // Mojibake like "â€\u009d" (for ”) carries C1 code points that libghostty prints as text. + const before = "café\t界🙂\u0007\u009c\u0085 â€\u009d Ð\u0090 \u009e\u009f\u009b6n"; const after = "e\u0301\r\n"; process.emitData(before); @@ -1272,17 +1278,18 @@ it.layer( process.emitData("\u001b[?2026$p\u001b[?2026;2$y\u001b[>q\u001b[?u\u001b[?31u"); process.emitData("\u001bP$q m\u001b\\\u001bP1$r0m\u001b\\"); process.emitData("\u001bP+q544e\u001b\\\u001bP1+r544e=1b\u001b\\"); - process.emitData("\u0090$q m\u009c\u00901$r0m\u009c"); - process.emitData("\u0090+q544e\u009c\u00901+r544e=1b\u009c"); process.emitData('\u001b[!p\u001b["p\u001b[4 q\u001b[u'); + process.emitData("\u001b]0;x\u001b\u001b[?2026$p"); process.emitData("done\n"); yield* manager.close({ threadId: "thread-1" }); const reopened = yield* manager.open(openInput()); - expect(reopened.history.startsWith('prompt \u001b[!p\u001b["p\u001b[4 q\u001b[udone\n')).toBe( - true, - ); + expect( + reopened.history.startsWith( + 'prompt \u001b[!p\u001b["p\u001b[4 q\u001b[u\u001b]0;x\u001bdone\n', + ), + ).toBe(true); }), ); @@ -1292,17 +1299,13 @@ it.layer( { name: "PM", sequence: "\u001b^unfinished" }, { name: "APC", sequence: "\u001b_unfinished" }, { name: "CSI", sequence: "\u001b[5" }, - { name: "C1 OSC", sequence: "\u009d0;unfinished" }, - { name: "C1 DCS", sequence: "\u0090unfinished" }, - { name: "C1 PM", sequence: "\u009eunfinished" }, - { name: "C1 APC", sequence: "\u009funfinished" }, - { name: "C1 CSI", sequence: "\u009b5" }, ])("keeps output after a cancelled $name sequence in history", ({ sequence }) => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); + const { manager, ptyAdapter } = yield* createManager(10); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]!; - for (const cancel of ["\u0018", "\u001a"]) { + // A new escape sequence also aborts one, as in xterm.js; keep it last so CAN cannot end it. + for (const cancel of ["\u0018", "\u001a", "\u001b[0m"]) { process.emitData(sequence); process.emitData(cancel); process.emitData("next line\n"); @@ -1311,7 +1314,7 @@ it.layer( yield* manager.close({ threadId: "thread-1" }); const reopened = yield* manager.open(openInput()); - expect(reopened.history.match(/next line/g)).toHaveLength(4); + expect(reopened.history.match(/next line/g)).toHaveLength(6); }), ); @@ -1329,10 +1332,10 @@ it.layer( process.emitData("\u001bP$q "); process.emitData("m\u001b"); process.emitData("\\after "); - process.emitData("\u009b?3"); + process.emitData("\u001b[?3"); process.emitData("1uafter "); - process.emitData("\u0090+q544e"); - process.emitData("\u009cafter\n"); + process.emitData("\u001bP+q544e"); + process.emitData("\u001b\\after\n"); yield* manager.close({ threadId: "thread-1" }); @@ -1355,10 +1358,10 @@ it.layer( yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); const sequences = [ { prefix: `\u001b]52;c;${"YWJj".repeat(16_384)}\u001b`, terminator: "\\" }, - { prefix: `\u0090q${"?".repeat(65_536)}`, terminator: "\u009c" }, + { prefix: `\u001bPq${"?".repeat(65_536)}`, terminator: "\u001b\\" }, ]; - const stripped = "\u001b]11;?\u0007\u0090$q m\u009c"; - const preserved = "\u001b[31m\u009d52;c;YWJj\u009c"; + const stripped = "\u001b]11;?\u0007\u001bP$q m\u001b\\"; + const preserved = "\u001b[31m\u001b]52;c;YWJj\u0007"; const expected = sequences.map(({ prefix, terminator }) => prefix + terminator + preserved).join("") + "done\n"; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index d2a8a24c323f3..0f7c39b7c667f 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -43,6 +43,7 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Context from "effect/Context"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; @@ -124,6 +125,8 @@ const TERMINAL_ENV_BLOCKLIST = new Set([ "ZELLIJ_SESSION_NAME", "ZELLIJ_PANE_ID", ]); +// Coder: sizes attach replay events for the bounded resume window. +const encodeAttachEventJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const MAX_TERMINAL_LABEL_LENGTH = 128; const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); @@ -1151,8 +1154,10 @@ function findStringTerminatorIndex(input: string, start: number): number | null if (codePoint === 0x07 || codePoint === 0x9c || isControlSequenceCancel(codePoint)) { return index + 1; } - if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) { - return index + 2; + // Like xterm.js, an ESC that does not start ST ends the string and begins the next sequence. + if (codePoint === 0x1b) { + if (index + 1 >= input.length) return null; + return input.charCodeAt(index + 1) === 0x5c ? index + 2 : index; } } return null; @@ -1174,10 +1179,15 @@ function findEscapeSequenceEndIndex(input: string, start: number): number | null if (cursor >= input.length) { return null; } + // A second ESC restarts the sequence, so leave it for the next pass. + if (input.charCodeAt(cursor) === 0x1b) { + return cursor; + } return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; } -const HISTORY_SEQUENCE_START = /[\u001b\u0090\u009b\u009d\u009e\u009f]/; +// libghostty prints UTF-8 C1 code points (U+0080-U+009F) as text, so only ESC starts a sequence. +const HISTORY_SEQUENCE_START = /\u001b/; const NEXT_HISTORY_SEQUENCE_START = new RegExp(HISTORY_SEQUENCE_START.source, "g"); function sanitizeTerminalHistoryChunk( @@ -1262,47 +1272,6 @@ function sanitizeTerminalHistoryChunk( continue; } - if (codePoint === 0x9b) { - let cursor = index + 1; - while (cursor < input.length) { - const cancelled = isControlSequenceCancel(input.charCodeAt(cursor)); - if (cancelled || isCsiFinalByte(input.charCodeAt(cursor))) { - const sequence = input.slice(index, cursor + 1); - const body = input.slice(index + 1, cursor); - if (cancelled || !shouldStripCsiSequence(body, input[cursor] ?? "")) { - append(sequence); - } - index = cursor + 1; - break; - } - cursor += 1; - } - if (cursor >= input.length) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - continue; - } - - if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) { - const terminatorIndex = findStringTerminatorIndex( - input, - Math.max(index + 1, pendingScanStart), - ); - if (terminatorIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - const sequence = input.slice(index, terminatorIndex); - const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); - const strip = - (codePoint === 0x9d && shouldStripOscSequence(content)) || - (codePoint === 0x90 && shouldStripDcsSequence(content)); - if (!strip) { - append(sequence); - } - index = terminatorIndex; - continue; - } - // One slice per printable run: every chunk of coloured output reaches this loop. NEXT_HISTORY_SEQUENCE_START.lastIndex = index + 1; const nextSequenceStart = NEXT_HISTORY_SEQUENCE_START.exec(input)?.index ?? input.length; @@ -1687,7 +1656,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const sequence = event.sequence; if (attachEvent !== null && sequence !== undefined) { const key = toSessionKey(event.threadId, event.terminalId); - const encodedBytes = Buffer.byteLength(JSON.stringify(attachEvent), "utf8"); + const encodedBytes = Buffer.byteLength(encodeAttachEventJson(attachEvent), "utf8"); let replay = attachReplay.get(key); if ( replay === undefined || @@ -2916,7 +2885,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func exitSignal: null, updatedAt: yield* nowIso, eventSequence: 0, - attachEpoch: crypto.randomUUID(), + attachEpoch: SecureRandom.uuidV4Unsafe(), inputCount: 0, cols, rows, diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index 4b83ddf6697e6..cc7ae638f7df2 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -24,6 +24,15 @@ export class NodePtyModuleLoadError extends Schema.TaggedError()("NodePtyCancelError", { + terminalPid: Schema.Number, + cause: Schema.Defect(), +}) { + override get message(): string { + return `Failed to cancel Windows terminal startup for ${this.terminalPid}.`; + } +} + type NodePtyModuleLoader = () => Promise; // node-pty's native addon stays external; single-executables need require to resolve disk modules and spawn helpers. @@ -134,18 +143,21 @@ const waitForWindowsPid = ( // The private agent cancels pending connections before a child exists; public kill waits for first output. const killStartingWindowsPty = (process: import("node-pty").IPty) => - Effect.try(() => { - if ( - "_agent" in process && - typeof process._agent === "object" && - process._agent !== null && - "kill" in process._agent && - typeof process._agent.kill === "function" - ) { - process._agent.kill(); - } else { - process.kill(); - } + Effect.try({ + try: () => { + if ( + "_agent" in process && + typeof process._agent === "object" && + process._agent !== null && + "kill" in process._agent && + typeof process._agent.kill === "function" + ) { + process._agent.kill(); + } else { + process.kill(); + } + }, + catch: (cause) => new NodePtyCancelError({ terminalPid: process.pid, cause }), }).pipe( Effect.catch((error) => Effect.logWarning("failed to cancel Windows terminal startup", { diff --git a/apps/server/src/terminal/OutputProtocol.ts b/apps/server/src/terminal/OutputProtocol.ts index 1a8e93381aae2..e52785d6985ed 100644 --- a/apps/server/src/terminal/OutputProtocol.ts +++ b/apps/server/src/terminal/OutputProtocol.ts @@ -50,7 +50,7 @@ export function withTerminalOutputWindow( } else if (response._tag === "Chunk") { const window = windows.get(`${clientId}:${response.requestId}`); if (window) { - // @effect-diagnostics-next-line preferSchemaOverJson:off + // @effect-diagnostics-next-line globalJsonInEffect:off const size = Buffer.byteLength(JSON.stringify(response)); window.push(size); if (!isFull(window)) { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 197d412d93405..78b8724e1f93c 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -75,136 +75,34 @@ function withFakeClaudeEnv( const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); const binDir = yield* makeFakeClaudeBinary(tempDir, input.lifecyclePort); const pathDelimiter = (yield* HostProcess.isWindows) ? ";" : ":"; - const previousPath = process.env.PATH; - const previousRejectsNoSessionPersistence = - process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE; - const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; - const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; - const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR; - const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; - const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; - const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - const previousConfigDirMustBe = process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; - const previousCwdMustNotBe = process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE; - - yield* Effect.acquireRelease( - Effect.sync(() => { - process.env.PATH = `${binDir}${pathDelimiter}${previousPath ?? ""}`; - process.env.T3_FAKE_CLAUDE_OUTPUT = input.output; - - if (input.rejectsNoSessionPersistence) { - process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE = "1"; - } else { - delete process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE; - } - - if (input.exitCode !== undefined) { - process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode); - } else { - delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; - } - - if (input.stderr !== undefined) { - process.env.T3_FAKE_CLAUDE_STDERR = input.stderr; - } else { - delete process.env.T3_FAKE_CLAUDE_STDERR; - } - - if (input.argsMustContain !== undefined) { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = input.argsMustContain; - } else { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; - } - - if (input.argsMustNotContain !== undefined) { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = input.argsMustNotContain; - } else { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; - } - - if (input.stdinMustContain !== undefined) { - process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = input.stdinMustContain; - } else { - delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - } - - if (input.cwdMustNotBe !== undefined) { - process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE = input.cwdMustNotBe; - } else { - delete process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE; - } - - if (input.configDirMustBe !== undefined) { - process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = input.configDirMustBe; - } else { - delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; - } - }), - () => - Effect.sync(() => { - process.env.PATH = previousPath; - - if (previousRejectsNoSessionPersistence === undefined) { - delete process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE; - } else { - process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE = - previousRejectsNoSessionPersistence; - } - - if (previousOutput === undefined) { - delete process.env.T3_FAKE_CLAUDE_OUTPUT; - } else { - process.env.T3_FAKE_CLAUDE_OUTPUT = previousOutput; - } - - if (previousExitCode === undefined) { - delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; - } else { - process.env.T3_FAKE_CLAUDE_EXIT_CODE = previousExitCode; - } - - if (previousStderr === undefined) { - delete process.env.T3_FAKE_CLAUDE_STDERR; - } else { - process.env.T3_FAKE_CLAUDE_STDERR = previousStderr; - } - - if (previousArgsMustContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previousArgsMustContain; - } - - if (previousArgsMustNotContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previousArgsMustNotContain; - } - - if (previousStdinMustContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain; - } - - if (previousCwdMustNotBe === undefined) { - delete process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE; - } else { - process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE = previousCwdMustNotBe; - } - - if (previousConfigDirMustBe === undefined) { - delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; - } else { - process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = previousConfigDirMustBe; - } - }), - ); + const hostEnv = yield* HostProcess.Environment; + const fakeClaudeEnv: Record = { + T3_FAKE_CLAUDE_OUTPUT: input.output, + T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE: input.rejectsNoSessionPersistence + ? "1" + : undefined, + T3_FAKE_CLAUDE_EXIT_CODE: input.exitCode === undefined ? undefined : String(input.exitCode), + T3_FAKE_CLAUDE_STDERR: input.stderr, + T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN: input.argsMustContain, + T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN: input.argsMustNotContain, + T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN: input.stdinMustContain, + T3_FAKE_CLAUDE_CWD_MUST_NOT_BE: input.cwdMustNotBe, + T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE: input.configDirMustBe, + }; + const environment: NodeJS.ProcessEnv = { + ...Object.fromEntries( + Object.entries(hostEnv).filter(([name]) => !name.startsWith("T3_FAKE_CLAUDE_")), + ), + ...Object.fromEntries( + Object.entries(fakeClaudeEnv).filter(([, value]) => value !== undefined), + ), + PATH: `${binDir}${pathDelimiter}${hostEnv.PATH ?? ""}`, + }; const config = decodeClaudeSettings(input.claudeConfig ?? {}); const textGeneration = yield* makeClaudeTextGeneration( config, - undefined, + environment, Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), ); return yield* effectFn(textGeneration); diff --git a/apps/server/src/textGeneration/ThreadTitleLinks.test.ts b/apps/server/src/textGeneration/ThreadTitleLinks.test.ts index ccb4cbfde9fce..d7a34a70884e9 100644 --- a/apps/server/src/textGeneration/ThreadTitleLinks.test.ts +++ b/apps/server/src/textGeneration/ThreadTitleLinks.test.ts @@ -20,7 +20,7 @@ it.effect( const result = yield* resolveThreadTitleLinks({ cwd: "/tmp/project", message: - "https://docs.test/guide [https://forge.test/change/1] https://forge.test/change/1#discussion https://forge.test/change/1?view=full `https://forge.test/change/2` https://forge.test/change/2. https://forge.test/change/3", + "https://docs.test/guide **https://forge.test/change/1**: [https://forge.test/change/1] https://forge.test/change/1#discussion https://forge.test/change/1?view=full `https://forge.test/change/2` https://forge.test/change/2. https://forge.test/change/3", }).pipe( Effect.provide( layerRegistry({ diff --git a/apps/server/src/textGeneration/ThreadTitleLinks.ts b/apps/server/src/textGeneration/ThreadTitleLinks.ts index 695e93e1f2d33..cb6f0d924d275 100644 --- a/apps/server/src/textGeneration/ThreadTitleLinks.ts +++ b/apps/server/src/textGeneration/ThreadTitleLinks.ts @@ -13,7 +13,7 @@ export const resolveThreadTitleLinks = Effect.fn("resolveThreadTitleLinks")(func const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const links = new Map>>(); for (const match of input.message.matchAll(/https:\/\/[^\s<>"')\]`]+/g)) { - const candidate = match[0].replace(/[.,;!?]+$/, ""); + const candidate = match[0].replace(/[.,:;!?*]+$/, ""); if (!URL.canParse(candidate)) continue; const url = new URL(candidate); url.hash = ""; diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 06f17625c2c79..d0b252bae31de 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -32,11 +33,11 @@ const layerServerConfig = ServerConfig.layerTest(process.cwd(), { const layerGitContract = Layer.mergeAll(GitVcsDriver.layerVcs, GitVcsDriver.layer).pipe( Layer.provide(layerServerConfig), Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const layerGitCaptureContract = Layer.merge( layerGitContract, - ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)), + ProcessRunner.layer.pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); const runGit = (cwd: string, args: ReadonlyArray) => @@ -273,18 +274,11 @@ it.effect("checkpoint recovery discovers nested HEAD independently of inherited const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); yield* git(["init", "empty"]); const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); - yield* Effect.acquireUseRelease( - Effect.sync(() => { - const previous = process.env.GIT_DIR; - process.env.GIT_DIR = path.join(cwd, ".git"); - return previous; + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }).pipe( + Effect.provideService(HostProcess.Environment, { + ...(yield* HostProcess.Environment), + GIT_DIR: path.join(cwd, ".git"), }), - () => driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }), - (previous) => - Effect.sync(() => { - if (previous === undefined) delete process.env.GIT_DIR; - else process.env.GIT_DIR = previous; - }), ); assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); assert.strictEqual((yield* git(["ls-tree", "-r", checkpointRef, "--", "empty"])).stdout, ""); @@ -319,6 +313,26 @@ it.effect("checkpoint capture still fails when a clean filter rejects a file", ( }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +it.effect("checkpoint capture keeps LF files that core.safecrlf would refuse to commit", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-safecrlf-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* fileSystem.writeFileString(path.join(cwd, ".gitattributes"), "*.txt text eol=crlf\n"); + yield* git(["config", "core.safecrlf", "true"]); + yield* fileSystem.writeFileString(path.join(cwd, "untracked.txt"), "new\n"); + const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + assert.strictEqual((yield* git(["show", `${checkpointRef}:untracked.txt`])).stdout, "new\n"); + assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect("checkpoint capture refuses a truncated nested repository listing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -593,7 +607,17 @@ it.effect.each( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const liveProcess = yield* VcsProcess.VcsProcess; + let freshReadTrees = 0; + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + if (input.args.includes("read-tree") && input.args.includes("index.sparse=true")) + freshReadTrees++; + return liveProcess.run(input); + }, + }), + ); const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); const { git } = yield* makeCheckpointFixture(driver, cwd); const write = Effect.fn(function* (name: string, contents: string) { @@ -676,11 +700,12 @@ it.effect.each( "working outside\n", ); } + assert.strictEqual(freshReadTrees, indexState === "missing" ? 2 : 0); }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); it.effect.each( - (["normal", "flags", "sparse"] as const).flatMap((indexMode) => + (["normal", "skip-worktree", "assume-unchanged", "sparse"] as const).flatMap((indexMode) => [1, 3, 4096].map((chunkSize) => ({ indexMode, chunkSize })), ), )( @@ -696,6 +721,7 @@ it.effect.each( yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n"); yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n"); yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n"); + yield* fs.writeFileString(path.join(cwd, "z-deleted"), "original\n"); yield* fs.makeDirectory(path.join(cwd, "excluded")); yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n"); yield* fs.writeFileString( @@ -703,19 +729,31 @@ it.effect.each( 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);', ); yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* git(["config", "core.trustctime", "false"]); yield* fs.utimes(path.join(cwd, "stable"), 1_700_000_000, 1_700_000_000); + yield* fs.utimes(path.join(cwd, "z-skipped"), 1_700_000_000, 1_700_000_000); yield* git(["add", "."]); yield* git(["commit", "-m", "inspection fixture"]); - if (indexMode === "flags") yield* git(["update-index", "--skip-worktree", "z-skipped"]); + const flagged = indexMode === "skip-worktree" || indexMode === "assume-unchanged"; + if (flagged) { + yield* git(["update-index", `--${indexMode}`, "z-skipped", "z-deleted"]); + yield* git(["config", "core.ignorestat", "true"]); + yield* fs.remove(path.join(cwd, "z-deleted")); + } if (indexMode === "sparse") yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]); yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n"); + // A flagged edit with the indexed size and mtime is invisible to Git's stat check. + if (flagged) yield* fs.utimes(path.join(cwd, "z-skipped"), 1_700_000_000, 1_700_000_000); yield* fs.writeFileString(path.join(cwd, ".git/reads"), ""); const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); + let freshReadTrees = 0; const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => - liveProcess.run( + run: (input) => { + if (input.args.includes("read-tree") && !input.args.includes("--reset")) + freshReadTrees++; + return liveProcess.run( input.args.includes("ls-files") ? { ...input, @@ -726,13 +764,19 @@ it.effect.each( }, } : input, - ), + ); + }, }), ); yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); assert.strictEqual((yield* git(["show", `${checkpointRef}:z-skipped`])).stdout, "modified\n"); - if (indexMode !== "flags") - assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), ""); + const files = (yield* git(["ls-tree", "-z", "--name-only", checkpointRef])).stdout.split( + "\0", + ); + if (flagged) assert.notInclude(files, "z-deleted"); + else assert.include(files, "z-deleted"); + assert.strictEqual(freshReadTrees, 0); + assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), ""); assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); @@ -1100,6 +1144,47 @@ it.effect("restores empty checkpoints without changing paths outside the workspa }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +it.effect("restores a checkpoint before the first commit without staging its files", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + for (const nested of [false, true]) { + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-unborn-restore-" }); + yield* runGit(root, ["init"]); + yield* runGit(root, ["config", "user.email", "test@test.com"]); + yield* runGit(root, ["config", "user.name", "Test"]); + if (nested) { + yield* fileSystem.writeFileString(path.join(root, "outside.txt"), "staged\n"); + yield* runGit(root, ["add", "outside.txt"]); + } + const cwd = nested ? path.join(root, "nested") : root; + yield* fileSystem.makeDirectory(cwd, { recursive: true }); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "original\n"); + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/unborn"); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "changed\n"); + yield* fileSystem.writeFileString(path.join(cwd, "added.txt"), "new\n"); + + assert.isTrue( + yield* driver.checkpoints.restoreCheckpoint({ cwd, checkpointRef, fallbackToHead: false }), + ); + + assert.strictEqual( + yield* fileSystem.readFileString(path.join(cwd, "file.txt")), + "original\n", + ); + assert.isFalse(yield* fileSystem.exists(path.join(cwd, "added.txt"))); + const staged = yield* driver.execute({ + operation: "test", + cwd: root, + args: ["diff", "--cached", "--name-only"], + }); + assert.strictEqual(staged.stdout.trim(), nested ? "outside.txt" : ""); + } + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + // The real VCS process, spawning `command` wherever the driver runs git. const vcsProcessSpawning = (command: string) => Layer.effect( @@ -1107,7 +1192,10 @@ const vcsProcessSpawning = (command: string) => Effect.map(VcsProcess.make, (vcsProcess) => VcsProcess.VcsProcess.of({ run: (input) => vcsProcess.run({ ...input, command }) }), ), - ).pipe(Layer.provide(ProcessRunner.layer), Layer.provideMerge(NodeServices.layer)); + ).pipe( + Layer.provide(ProcessRunner.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ); it.effect("GitVcsDriver treats a missing git executable as outside any repository", () => Effect.gen(function* () { @@ -1120,7 +1208,7 @@ it.effect("GitVcsDriver treats a missing git executable as outside any repositor assert.strictEqual(yield* driver.isInsideWorkTree(cwd), false); assert.strictEqual(yield* driver.detectRepository(cwd), null); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( @@ -1156,7 +1244,7 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( assert.strictEqual(error._tag, "VcsProcessSpawnError"); const detectionError = yield* driver.detectRepository(process.cwd()).pipe(Effect.flip); assert.strictEqual(detectionError._tag, "VcsProcessSpawnError"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // Coder: upstream's Git command metric tests are not carried; the fork exports no metrics. diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 0ab293a7a7d9d..c620e4dbad2bf 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1,7 +1,7 @@ import * as NodeBuffer from "node:buffer"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -460,6 +460,7 @@ export class GitVcsDriver extends Context.Service< >()("t3/vcs/GitVcsDriver") {} const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const FLAGGED_INDEX_ENTRIES_MAX_BYTES = 1024 * 1024; const CHECKPOINT_RECOVERY_MAX_CANDIDATES = 64; const CHECKPOINT_RECOVERY_TIMEOUT = "5 seconds"; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; @@ -582,7 +583,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const vcsProcess = yield* VcsProcess.VcsProcess; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const hostPlatform = yield* HostProcess.Platform; const isInsideWorkTree: VcsDriver.VcsDriver["Service"]["isInsideWorkTree"] = (cwd) => gitCommand( @@ -926,10 +927,11 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "splitIndex.sharedIndexExpire=never", ]; const { gitCommonDir, indexPath, headExists } = yield* resolveCaptureRepository(input.cwd); - const indexId = yield* crypto.randomUUIDv4().pipe(Effect.orDie); + const indexId = yield* random.uuidV4(); const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`); + const hostEnv = yield* HostProcess.Environment; const commitEnv: NodeJS.ProcessEnv = { - ...process.env, + ...hostEnv, GIT_INDEX_FILE: tempIndexPath, // Coder: workspace checkpoints use the fork's local commit identity. GIT_AUTHOR_NAME: "T3 Coder", @@ -984,43 +986,60 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }); // read-tree can rewrite the index, so restore its racy timestamp afterward. yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); - let specialFlags = false; + // Manually flagged entries hide edits from add -A, so they are re-added below + // with no stat cache. Every other entry keeps its stat data and is not rehashed. + let fallback = false; + let flaggedBytes = 0; let recordStart = true; - let skipped = false; - let skippedRecord: Uint8Array[] = []; - const skippedPaths: string[] = []; + let collect: "flagged" | "skipped" | undefined; + let record: Uint8Array[] = []; + const flagged: string[] = []; + const skipped: string[] = []; yield* vcsProcess.run({ operation, command: "git", cwd: input.cwd, - args: [...indexConfig, "ls-files", "--full-name", "--sparse", "-v", "-z"], + args: [...indexConfig, "ls-files", "--full-name", "--sparse", "--stage", "-v", "-z"], env: commitEnv, maxOutputBytes: 4_096, outputMode: "truncate", - // Only tags and sparse skip-worktree paths matter; scan NUL-delimited records. + // Only tags and flagged or skip-worktree records matter; scan NUL-delimited records. onStdoutChunk: (chunk) => { let start = 0; - while (start < chunk.length) { + while (start < chunk.length && !fallback) { const end = chunk.indexOf(0, start); if (recordStart) { const tag = chunk[start]!; - skipped = tag === 83; - if ((tag >= 97 && tag <= 122) || (!sparseCheckout && tag === 83)) { - specialFlags = true; - } + record = []; + // Sparse skip-worktree bits are expected; only in-cone ones are manual. + collect = + tag === 83 && sparseCheckout + ? "skipped" + : (tag >= 97 && tag <= 122) || tag === 83 + ? "flagged" + : undefined; + // index-info cannot keep skip-worktree while dropping assume-unchanged. + if (tag === 115 && sparseCheckout) fallback = true; } - if (skipped && sparseCheckout) { - skippedRecord.push( - Buffer.from(chunk.subarray(start, end === -1 ? undefined : end)), - ); - if (end !== -1) { - const record = Buffer.concat(skippedRecord); - if (record.at(-1) !== 47) { - const name = record.subarray(2); - if (!NodeBuffer.isUtf8(name)) specialFlags = true; - else skippedPaths.push(name.toString("utf8")); + if (collect !== undefined && !fallback) { + const piece = chunk.subarray(start, end === -1 ? undefined : end); + record.push(Buffer.from(piece)); + if ( + collect === "flagged" && + (flaggedBytes += piece.length) > FLAGGED_INDEX_ENTRIES_MAX_BYTES + ) + fallback = true; + if (end !== -1 && !fallback) { + const full = Buffer.concat(record); + record = []; + // Sparse directory entries stay collapsed. + if (collect === "flagged" || full.at(-1) !== 47) { + // Drop the " " prefix, leaving index-info's " \t". + const entry = full.subarray(2); + if (!NodeBuffer.isUtf8(entry)) fallback = true; + else + (collect === "flagged" ? flagged : skipped).push(entry.toString("utf8")); } - skippedRecord = []; } } recordStart = end !== -1; @@ -1029,20 +1048,46 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } }, }); - if (skippedPaths.length > 0 && !specialFlags) { - const selected = yield* execute({ + if (fallback) return false; + if (skipped.length > 0) { + const paths = skipped.map((entry) => entry.slice(entry.indexOf("\t") + 1)); + const stdin = paths.join("\0") + "\0"; + // check-rules echoes a subset of its input, so the input size bounds its output. + const rules = yield* execute({ operation, cwd: input.cwd, args: [...indexConfig, "sparse-checkout", "check-rules", "-z"], - stdin: skippedPaths.join("\0") + "\0", + stdin, env: commitEnv, - maxOutputBytes: 1, - outputMode: "truncate", + maxOutputBytes: Buffer.byteLength(stdin), + outputMode: "error", + }); + const selected = new Set(rules.stdout.split("\0")); + skipped.forEach((entry, index) => { + if (selected.has(paths[index]!)) flagged.push(entry); }); - specialFlags = selected.stdout.length > 0 || selected.stdoutTruncated; } - // Sparse Git clears skip-worktree for present files. Manual flags still need a reset. - return !specialFlags; + if (flagged.length > 0) { + // Flagged gitlinks keep the fresh-index behaviour rather than a guessed one. + if (flagged.some((entry) => entry.startsWith("160000 "))) return false; + yield* execute({ + operation, + cwd: input.cwd, + // core.ignorestat would mark these assume-unchanged, hiding edits from add -A. + args: [ + ...indexConfig, + "-c", + "core.ignorestat=false", + "update-index", + "-z", + "--index-info", + ], + stdin: flagged.join("\0") + "\0", + env: commitEnv, + }); + yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); + } + return true; }).pipe(Effect.orElseSucceed(() => false)); if (!reusedIndex) { if (sparseCheckout) { @@ -1141,6 +1186,9 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ...indexConfig, ...durableWrite, ...longPaths, + // safecrlf=true refuses LF files under CRLF conversion; capture them as Git's default does. + "-c", + "core.safecrlf=false", "add", ...(sparseCheckout ? ["--sparse"] : []), // add -A rejects ignored directories even when they contain tracked files. @@ -1177,7 +1225,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ); if (candidates.length > CHECKPOINT_RECOVERY_MAX_CANDIDATES) return yield* error; const nestedRepoEnv: NodeJS.ProcessEnv = { - ...process.env, + ...hostEnv, GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_COMMON_DIR: undefined, @@ -1340,14 +1388,11 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( // On Windows the same clean can remove './' and exit 0. yield* recreateWorkspace; - const headExists = yield* hasHeadCommit(input.cwd); - if (headExists) { - yield* execute({ - operation, - cwd: input.cwd, - args: [...longPaths, "reset", "--quiet", "--", "."], - }); - } + yield* execute({ + operation, + cwd: input.cwd, + args: [...longPaths, "reset", "--quiet", "--", "."], + }); return true; }), @@ -1394,11 +1439,14 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( // Not --no-relative, which Git before 2.28 rejects. "-c", "diff.relative=false", + "-c", + "diff.suppressBlankEmpty=false", "diff", ...(input.format === "numstat" ? ["--numstat", "-z"] : ["--patch"]), "--no-color", "--no-ext-diff", "--no-textconv", + "--submodule=short", ...PATCH_RENDER_PREFIX_ARGS, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${fromRevision}^{commit}`, @@ -1453,6 +1501,9 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( args: [ "-c", "diff.relative=false", + // log.showSignature prints verification text into the path list. + "-c", + "log.showSignature=false", ...args, "--name-only", "-z", diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 56b9547fad5b0..71cc2b05a1b00 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2,6 +2,7 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodeURL from "node:url"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it, describe } from "@effect/vitest"; @@ -21,7 +22,7 @@ import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { ChildProcess, ChildProcessSpawner, CommandLine } from "effect/process"; import { GitCommandError, @@ -52,7 +53,7 @@ const layerServerConfig = ServerConfig.layerTest(process.cwd(), { }); const layerTest = GitVcsDriver.layer.pipe( Layer.provideMerge(layerServerConfig), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const makeNonRepositoryHandle = () => @@ -104,7 +105,8 @@ const makeDriverReleasingLock = ( const spawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { const handle = yield* delegate.spawn(command); - if (!ChildProcess.isStandardCommand(command) || !matches(command.args)) return handle; + if (!ChildProcess.isStandardCommand(command) || !matches(command.args.map(String))) + return handle; return ChildProcessSpawner.makeHandle({ ...handle, exitCode: handle.exitCode.pipe( @@ -179,7 +181,13 @@ describe("Git output collection", () => { assert.deepEqual(withCallbacks, expected); assert.deepEqual(stdoutLines, ["a\\b\0雪", "last "]); assert.deepEqual(stderrLines, ["Receiving é", "Updating 雪", "done"]); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + }).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("caps output without line callbacks", () => @@ -199,7 +207,13 @@ describe("Git output collection", () => { const error = yield* driver.execute(input).pipe(Effect.flip); assert.equal(error.outputLength, 4); assert.include(error.detail, "exceeded 3 bytes"); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + }).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); }); @@ -255,6 +269,14 @@ const git = ( return result.stdout.trim(); }); +// Git blocks file submodule transports (CVE-2022-39253) despite repo config; local fixtures need env permission. +const withGitFileProtocol = (effect: Effect.Effect) => + Effect.flatMap(HostProcess.Environment, (env) => + effect.pipe( + Effect.provideService(HostProcess.Environment, { ...env, GIT_ALLOW_PROTOCOL: "file" }), + ), + ); + const initRepoWithCommit = ( cwd: string, ): Effect.Effect< @@ -357,7 +379,7 @@ it.effect.each([ if (!ChildProcess.isStandardCommand(command)) { return yield* Effect.die("expected a standard Git command"); } - spawnedArgs.push(command.args); + spawnedArgs.push(command.args.map(String)); if (invalidationFailure !== "none" && command.args.includes("--git-common-dir")) { if (invalidationFailure === "non-repository") return makeNonRepositoryHandle(); return ChildProcessSpawner.makeHandle({ @@ -450,7 +472,7 @@ it.effect.each(["branch", "detached", "nested", "aliased-root", "aliased-nested" const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const spawnedArgs: Array> = []; const spawner = ChildProcessSpawner.make((command) => { - if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args); + if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args.map(String)); return delegate.spawn(command); }); const driver = yield* makeGitVcsDriverCore().pipe( @@ -517,7 +539,7 @@ it.effect.each(["bare", "unborn"] as const)( const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const spawnedArgs: Array> = []; const spawner = ChildProcessSpawner.make((command) => { - if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args); + if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args.map(String)); return delegate.spawn( state === "bare" ? ChildProcess.setEnv(command, { GIT_DIR: cwd }) : command, ); @@ -555,6 +577,119 @@ it.effect.each(["bare", "unborn"] as const)( ).pipe(Effect.provide(layerTest)), ); +it.effect.each(["on a branch", "detached", "unborn"] as const)( + "reads only the repository root for review requests that ignore the branch when %s", + (state) => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnedArgs: Array> = []; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args.map(String)); + return delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const discoveries = () => { + const commands = spawnedArgs.filter( + (args) => + args.includes("--show-toplevel") || + args.includes("--git-common-dir") || + (args.includes("symbolic-ref") && args.includes("HEAD")), + ); + spawnedArgs.length = 0; + return commands.length; + }; + const cwd = yield* makeTmpDir(); + if (state === "unborn") { + yield* git(cwd, ["init", "-b", "main"]); + } else { + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "committed.txt", "committed\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add file"]); + if (state === "detached") yield* git(cwd, ["checkout", "--detach"]); + } + yield* writeTextFile(cwd, "new.txt", "new\n"); + spawnedArgs.length = 0; + + const workingTree = yield* driver.getReviewDiffPreview({ + cwd, + file: { path: "new.txt", previousPath: null, sourceKind: "working-tree" }, + }); + assert.equal(discoveries(), 1); + assert.deepStrictEqual( + workingTree.sources + .find((source) => source.kind === "working-tree")! + .files!.map((file) => file.path), + ["new.txt"], + ); + + if (state !== "unborn") { + const commitSha = yield* git(cwd, ["rev-parse", "HEAD"]); + spawnedArgs.length = 0; + const commit = yield* driver.getReviewDiffPreview({ + cwd, + commitSha, + file: { path: "committed.txt", previousPath: null, sourceKind: "commit" }, + }); + assert.equal(discoveries(), 1); + assert.include(commit.sources[0]!.diff, "+committed"); + } + + const general = yield* driver.getReviewDiffPreview({ cwd }); + assert.equal(discoveries(), 3); + assert.equal( + general.sources.find((source) => source.kind === "branch-range")!.headRef, + state === "detached" ? "HEAD" : yield* git(cwd, ["branch", "--show-current"]), + ); + }), + ).pipe(Effect.provide(layerTest)), +); + +it.effect.each([ + { kind: "working-tree", request: { sourceKind: "working-tree" } }, + { kind: "commit", request: { sourceKind: "commit", commitSha: "HEAD" } }, +] as const)("keeps $kind review requests safe without a worktree root", ({ request }) => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const bareRoot = yield* makeTmpDir("git-review-bare-"); + const spawner = ChildProcessSpawner.make((command) => + delegate.spawn( + ChildProcess.isStandardCommand(command) && command.options.cwd === bareRoot + ? ChildProcess.setEnv(command, { GIT_DIR: bareRoot }) + : command, + ), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const path = yield* Path.Path; + const file = { path: "README.md", previousPath: null, sourceKind: request.sourceKind }; + const input = (cwd: string) => ({ + cwd, + file, + ...("commitSha" in request ? { commitSha: request.commitSha } : {}), + }); + const source = yield* makeTmpDir(); + yield* initRepoWithCommit(source); + yield* git(source, ["clone", "--bare", source, bareRoot]); + const nonRepository = yield* makeTmpDir(); + for (const cwd of [bareRoot, nonRepository, path.join(nonRepository, "missing")]) { + assert.isEmpty((yield* driver.getReviewDiffPreview(input(cwd))).sources); + } + + const brokenConfig = yield* makeTmpDir(); + yield* initRepoWithCommit(brokenConfig); + yield* writeTextFile(brokenConfig, ".git/config", "[broken"); + const error = yield* driver.getReviewDiffPreview(input(brokenConfig)).pipe(Effect.flip); + assert.equal(error.operation, "GitVcsDriver.resolveRepositoryPaths.commonDir"); + }), + ).pipe(Effect.provide(layerTest)), +); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "preserves embedded newlines in repository paths after worktree creation", () => @@ -763,7 +898,7 @@ describe("pullCurrentBranch", () => { ChildProcess.isStandardCommand(command) && command.args.includes("pull") && command.args.includes("--ff-only"); - if (isPull) onPull?.(command.args); + if (isPull) onPull?.(command.args.map(String)); return isPull ? ChildProcessSpawner.makeHandle({ ...handle, @@ -924,7 +1059,13 @@ describe("pullCurrentBranch", () => { assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), remoteHead); }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("still fails a pull that Git rejects, whatever its output size", () => @@ -946,7 +1087,13 @@ describe("pullCurrentBranch", () => { assert.lengthOf(pulls, 1); }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); }); @@ -1006,7 +1153,13 @@ it.effect("retries an in-flight ref snapshot invalidated by a mutation", () => assert.isTrue(refs.refs.some((ref) => ref.name === "feature/during-refresh")); assert.equal(yield* Ref.get(refScans), 2); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("invalidates a ref snapshot when a mutation fails after changing Git", () => @@ -1038,7 +1191,13 @@ it.effect("invalidates a ref snapshot when a mutation fails after changing Git", const refs = yield* driver.listRefs({ cwd }); assert.isTrue(refs.refs.some((ref) => ref.name === "feature/partial-failure")); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("marks the current branch when worktree metadata is unavailable", () => @@ -1073,7 +1232,13 @@ it.effect("marks the current branch when worktree metadata is unavailable", () = assert.isTrue(refs.isRepo); assert.isTrue(refs.refs.find((ref) => ref.name === initialBranch)?.current); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("ignores worktree metadata for directories that no longer exist", () => @@ -1109,7 +1274,13 @@ it.effect("ignores worktree metadata for directories that no longer exist", () = assert.equal(refs.refs.find((ref) => ref.name === "stale-worktree")?.worktreePath, null); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("reads worktrees from git that rejects worktree list -z", () => @@ -1160,7 +1331,13 @@ it.effect("reads worktrees from git that rejects worktree list -z", () => worktreePath, ); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("does not report a separate git dir as the worktree of its branch", () => @@ -1273,7 +1450,7 @@ it.effect.each([ return yield* Effect.die("expected Git command"); if (command.args[0] !== "fetch") return makeNonRepositoryHandle(); assert.deepEqual(command.args, ["fetch", "--quiet", "--", "origin"]); - assert.equal(command.options.env?.LC_ALL, "C"); + assert.equal(command.options.env?.LANGUAGE, "C"); assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0"); yield* Ref.update(attempts, (count) => count + 1); return ChildProcessSpawner.makeHandle({ @@ -1305,7 +1482,13 @@ it.effect.each([ assert.notProperty(error, "stderr"); assert.notProperty(error, "args"); assert.equal(yield* Ref.get(attempts), "attempts" in scenario ? scenario.attempts : 1); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + }).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("retries a fetch that lost a ref update to another Git process", () => @@ -1335,7 +1518,43 @@ it.effect("retries a fetch that lost a ref update to another Git process", () => const cwd = yield* makeTmpDir(); yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "main" }); assert.equal(yield* Ref.get(attempts), 2); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + }).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), +); + +it.effect("does not fetch for status when the branch tracks a local branch", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const fetches: Array> = []; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command) && command.args.includes("fetch")) { + fetches.push(command.args.map(String)); + } + return delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "feature", remote]); + yield* git(cwd, ["branch", "feature/base"]); + yield* git(cwd, ["checkout", "-b", "stacked", "--track", "feature/base"]); + + const status = yield* driver.statusDetailsRemote(cwd); + + assert.equal(status.upstreamRef, "feature/base"); + assert.deepStrictEqual(fetches, []); + }), + ).pipe(Effect.provide(layerTest)), ); it.effect("lets a worktree checkout that is still working run past five minutes", () => @@ -1383,7 +1602,13 @@ it.effect("lets a worktree checkout that is still working run past five minutes" const result = yield* Fiber.join(creation); assert.equal(result.worktree.path, worktreePath); assert.equal(result.worktree.refName, "t3/slow-checkout"); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + }).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("cancels a slow submodule checkout without reporting best-effort success", () => @@ -1438,7 +1663,13 @@ it.effect("cancels a slow submodule checkout without reporting best-effort succe assert.isTrue(yield* Deferred.isDone(updateStopped)); assert.equal(yield* Ref.get(claimed), worktreePath); assert.isEmpty(yield* Ref.get(finished)); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + }).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); describe("rejected git refs", () => { @@ -1458,7 +1689,7 @@ describe("rejected git refs", () => { Layer.provide(layerServerConfig), Layer.provideMerge( Layer.merge( - NodeServices.layer, + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), ), ), @@ -1664,7 +1895,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* initRepoWithCommit(cwd); const path = yield* Path.Path; const refName = "private/deleted-base"; - const locales: Array = []; + const locales: Array = []; const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const spawner = ChildProcessSpawner.make((command) => { if ( @@ -1673,7 +1904,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { command.args[0] === "branch" || command.args[0] === "checkout") ) { - locales.push(command.options.env?.LC_ALL); + locales.push(command.options.env?.LANGUAGE); } return delegate.spawn(command); }); @@ -1950,6 +2181,14 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { .sources[0]!; assert.equal(rootSource.baseRef, emptyTree); assert.include(rootSource.diff, "+# test"); + const rootMetadata = (yield* driver.getReviewDiffPreview({ + cwd: upstream, + commitSha: root, + metadataOnly: true, + })).sources[0]!; + assert.deepStrictEqual(rootMetadata.files, rootSource.files); + assert.isTrue(rootMetadata.patchesDeferred); + assert.isEmpty(rootMetadata.diff); yield* writeTextFile(upstream, "README.md", "# boundary\n"); yield* git(upstream, ["commit", "-am", "boundary"]); @@ -1961,21 +2200,22 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); assert.equal(yield* git(cwd, ["rev-parse", "--is-shallow-repository"]), "true"); for (const request of [ - driver.getReviewDiffPreview({ cwd, commitSha: sha }), - driver.getReviewDiffFileContents( - makeReviewDiffFileContentsInput(cwd, { - sourceKind: "commit", - changeType: "new", - headRef: sha, - baseRef: emptyTree, - }), + Effect.asVoid(driver.getReviewDiffPreview({ cwd, commitSha: sha })), + // Coder: file contents can also fail with the chunked review size error. + Effect.asVoid( + driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { + sourceKind: "commit", + changeType: "new", + headRef: sha, + baseRef: emptyTree, + }), + ), ), ]) { - // Coder: file contents can also fail with the chunked review size error. - const error = yield* Effect.asVoid( - request as Effect.Effect, - ).pipe(Effect.flip); - assert.include(error.detail, "parent"); + const error = yield* Effect.flip(request); + assert.equal(error._tag, "GitCommandError"); + assert.include(error._tag === "GitCommandError" ? error.detail : "", "parent"); } }), ); @@ -2074,9 +2314,14 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("loads repository-relative files from a nested project directory", () => + it.effect("loads repository-relative files from nested directories and aliases", () => Effect.gen(function* () { - const cwd = yield* makeTmpDir(); + const directory = yield* makeTmpDir(); + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const platform = yield* HostProcess.Platform; + const cwd = path.join(directory, platform === "win32" ? "repository" : "repository\n "); + yield* fileSystem.makeDirectory(cwd); const { initialBranch } = yield* initRepoWithCommit(cwd); yield* git(cwd, ["checkout", "-b", "feature/nested"]); yield* writeTextFile(cwd, "nested/tracked.txt", "committed\n"); @@ -2084,27 +2329,37 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["commit", "-m", "nested file"]); yield* writeTextFile(cwd, "nested/tracked.txt", "changed\n"); yield* writeTextFile(cwd, "untracked.txt", "new\n"); - const path = yield* Path.Path; const driver = yield* GitVcsDriver.GitVcsDriver; const nestedCwd = path.join(cwd, "nested"); - const preview = yield* driver.getReviewDiffPreview({ - cwd: nestedCwd, - baseRef: initialBranch, - }); - assert.equal( - preview.sources.find((source) => source.kind === "working-tree")!.files!.length, - 2, - ); - for (const source of preview.sources) { - for (const file of source.files ?? []) { - const scoped = yield* driver.getReviewDiffPreview({ - cwd: nestedCwd, - baseRef: initialBranch, - file: { path: file.path, previousPath: file.previousPath, sourceKind: source.kind }, - }); - const patch = scoped.sources.find((item) => item.kind === source.kind)!; - assert.deepStrictEqual(patch.files, [file]); - assert.include(patch.diff, `b/${file.path}`); + const clone = path.join(nestedCwd, "clone"); + yield* fileSystem.makeDirectory(clone); + yield* initRepoWithCommit(clone); + const projectDirectories = [nestedCwd]; + if (symlinksSupported) { + const alias = path.join(directory, "alias"); + yield* fileSystem.symlink(nestedCwd, alias); + projectDirectories.push(alias); + } + for (const projectDirectory of projectDirectories) { + const preview = yield* driver.getReviewDiffPreview({ + cwd: projectDirectory, + baseRef: initialBranch, + }); + assert.equal( + preview.sources.find((source) => source.kind === "working-tree")!.files!.length, + 3, + ); + for (const source of preview.sources) { + for (const file of source.files ?? []) { + const scoped = yield* driver.getReviewDiffPreview({ + cwd: projectDirectory, + baseRef: initialBranch, + file: { path: file.path, previousPath: file.previousPath, sourceKind: source.kind }, + }); + const patch = scoped.sources.find((item) => item.kind === source.kind)!; + assert.deepStrictEqual(patch.files, [file]); + assert.include(patch.diff, `b/${file.path}`); + } } } }), @@ -2162,6 +2417,78 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect( + "returns an incomplete selected tracked preview while untracked discovery is held", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "README.md", "changed\n"); + yield* writeTextFile(cwd, "new.txt", "untracked\n"); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let untrackedReads = 0; + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (ChildProcess.isStandardCommand(command) && command.args.includes("--others")) { + assert.equal( + ++untrackedReads, + 1, + "tracked preview must not enumerate untracked files", + ); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(layerServerConfig), + ); + const full = yield* driver + .getReviewDiffPreview({ cwd, sourceKind: "working-tree" }) + .pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Effect.gen(function* () { + const preview = yield* driver.getReviewDiffPreview({ + cwd, + sourceKind: "working-tree", + trackedOnly: true, + }); + assert.isTrue(preview.incomplete); + assert.deepStrictEqual( + preview.sources.map((source) => source.kind), + ["working-tree"], + ); + assert.include(preview.sources[0]!.diff, "+changed"); + assert.notInclude(preview.sources[0]!.diff, "untracked"); + const metadata = yield* driver.getReviewDiffPreview({ + cwd, + sourceKind: "working-tree", + trackedOnly: true, + metadataOnly: true, + }); + assert.isTrue(metadata.incomplete); + assert.deepStrictEqual(metadata.sources[0]!.files, preview.sources[0]!.files); + assert.isTrue(metadata.sources[0]!.patchesDeferred); + assert.isEmpty(metadata.sources[0]!.diff); + }).pipe(Effect.ensuring(Deferred.succeed(release, undefined))); + const complete = yield* Fiber.join(full); + assert.isUndefined(complete.incomplete); + assert.include(complete.sources[0]!.diff, "+untracked"); + yield* writeTextFile(cwd, "README.md", "edited again\n"); + const refreshed = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: "missing-base", + sourceKind: "working-tree", + trackedOnly: true, + }); + assert.include(refreshed.sources[0]!.diff, "+edited again"); + }), + ); + it.effect("propagates patch failures instead of reporting an empty complete diff", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2182,6 +2509,16 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { if (Result.isFailure(result)) { assert.equal(result.failure.operation, "GitVcsDriver.getReviewDiffPreview.patch"); } + const metadata = yield* driver.getReviewDiffPreview({ + cwd, + sourceKind: "working-tree", + metadataOnly: true, + }); + assert.isTrue(metadata.sources[0]!.patchesDeferred); + assert.isEmpty(metadata.sources[0]!.diff); + assert.deepStrictEqual(metadata.sources[0]!.files, [ + { path: "README.md", previousPath: null, additions: 1, deletions: 1 }, + ]); }), ); @@ -2196,18 +2533,19 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () => + it.effect("keeps the patch format clients parse when the repository changes it", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); const driver = yield* GitVcsDriver.GitVcsDriver; yield* git(cwd, ["config", "diff.noprefix", "true"]); yield* git(cwd, ["config", "diff.mnemonicPrefix", "true"]); + yield* git(cwd, ["config", "diff.suppressBlankEmpty", "true"]); yield* git(cwd, ["checkout", "-b", "feature/noprefix"]); - yield* writeTextFile(cwd, "README.md", "# committed change\n"); + yield* writeTextFile(cwd, "README.md", "# test\n\ncommitted change\n"); yield* git(cwd, ["add", "README.md"]); yield* git(cwd, ["commit", "-m", "committed change"]); - yield* writeTextFile(cwd, "README.md", "# dirty change\n"); + yield* writeTextFile(cwd, "README.md", "# test\n\ndirty change\n"); yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); const preview = yield* driver.getReviewDiffPreview({ @@ -2219,6 +2557,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const workingTree = preview.sources.find((source) => source.kind === "working-tree")?.diff; const branchRange = preview.sources.find((source) => source.kind === "branch-range")?.diff; assert.include(workingTree, "diff --git a/README.md b/README.md"); + assert.include(workingTree, " # test\n \n-committed change\n+dirty change\n"); assert.include(workingTree, "+++ b/untracked.txt"); assert.include(branchRange, "diff --git a/README.md b/README.md"); }), @@ -2256,6 +2595,27 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reviews untracked files outside the sparse-checkout cone", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "excluded/tracked.ts", "tracked\n"); + yield* git(cwd, ["add", "excluded/tracked.ts"]); + yield* git(cwd, ["commit", "-m", "add excluded directory"]); + yield* git(cwd, ["sparse-checkout", "set", "--cone", "included"]); + yield* writeTextFile(cwd, "excluded/new.ts", "outside the cone\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + + const source = preview.sources.find((candidate) => candidate.kind === "working-tree")!; + assert.deepStrictEqual(source.files, [ + { path: "excluded/new.ts", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.include(source.diff, "+outside the cone"); + }), + ); + it.effect("detects an unstaged rename with edits without mutating a split index", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2312,52 +2672,75 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("keeps tracked changes visible when untracked discovery fails", () => - Effect.gen(function* () { - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const failingLsFilesSpawner = ChildProcessSpawner.make((command) => { - if (!ChildProcess.isStandardCommand(command)) { - return Effect.die("expected a standard Git command"); - } - return command.args[0] === "ls-files" && command.args[1] === "--others" - ? Effect.succeed(makeNonRepositoryHandle()) - : delegate.spawn(command); - }); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingLsFilesSpawner), - Effect.provide(layerServerConfig), - ); - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd).pipe( - Effect.provideService(GitVcsDriver.GitVcsDriver, driver), - ); - yield* writeTextFile(cwd, "README.md", "# tracked change\n"); - - const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); - const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + it.effect.each(["fails", "exceeds the output limit"] as const)( + "keeps tracked changes visible when untracked discovery %s", + (scenario) => + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const failingLsFilesSpawner = ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command)) { + return Effect.die("expected a standard Git command"); + } + if (command.args[0] !== "ls-files" || command.args[1] !== "--others") { + return delegate.spawn(command); + } + return Effect.succeed( + scenario === "fails" + ? makeNonRepositoryHandle() + : makeSuccessfulHandle("a".repeat(16 * 1024 * 1024 + 1)), + ); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingLsFilesSpawner), + Effect.provide(layerServerConfig), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + yield* writeTextFile(cwd, "README.md", "# tracked change\n"); - assert.include(diff, "-# test"); - assert.include(diff, "+# tracked change"); - }), + const preview = yield* driver.getReviewDiffPreview({ + cwd, + ignoreWhitespace: false, + metadataOnly: true, + }); + const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + + const source = preview.sources.find((source) => source.kind === "working-tree")!; + assert.isUndefined(source.patchesDeferred); + assert.isUndefined(source.files); + assert.isTrue(source.truncated); + assert.include(diff, "-# test"); + assert.include(diff, "+# tracked change"); + }), ); - it.effect("preserves a staged deletion when the removed path still exists", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* writeTextFile(cwd, "removed.txt", "remove me\n"); - yield* git(cwd, ["add", "removed.txt"]); - yield* git(cwd, ["commit", "-m", "add removable file"]); - yield* git(cwd, ["rm", "--cached", "removed.txt"]); + it.effect.each([ + { stagedCopy: false, expected: ["deleted file mode", "-remove me"] }, + { stagedCopy: true, expected: ["rename from removed.txt", "rename to copy.txt"] }, + ])( + "preserves a staged deletion when the removed path still exists (staged copy: $stagedCopy)", + ({ stagedCopy, expected }) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "removed.txt", "remove me\n"); + yield* git(cwd, ["add", "removed.txt"]); + yield* git(cwd, ["commit", "-m", "add removable file"]); + yield* git(cwd, ["rm", "--cached", "removed.txt"]); + if (stagedCopy) { + yield* writeTextFile(cwd, "copy.txt", "remove me\n"); + yield* git(cwd, ["add", "copy.txt"]); + } - const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); - const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; - assert.include(diff, "deleted file mode"); - assert.include(diff, "-remove me"); - assert.notInclude(diff, "new file mode"); - }), + for (const text of expected) assert.include(diff, text); + assert.notInclude(diff, "new file mode"); + }), ); it.effect.each([false, true])( @@ -2462,6 +2845,22 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.isTrue(dirty.truncated); assert.notInclude(branch.diff, "z-last.txt"); for (const source of [branch, dirty]) { + const selected = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + sourceKind: source.kind === "working-tree" ? "working-tree" : "branch-range", + }); + assert.deepStrictEqual(selected.sources, [source]); + const metadata = (yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + sourceKind: source.kind === "working-tree" ? "working-tree" : "branch-range", + metadataOnly: true, + })).sources[0]!; + assert.deepStrictEqual(metadata.files, source.files); + assert.isTrue(metadata.patchesDeferred); + assert.isFalse(metadata.truncated); + assert.isEmpty(metadata.diff); for (const file of source.files ?? []) { const individual = yield* driver.getReviewDiffPreview({ cwd, @@ -2638,34 +3037,61 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("reads range patches from source without running textconv", () => + it.effect("reads raw range context regardless of Git display configuration", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); yield* git(cwd, ["config", "diff.probe.textconv", "touch textconv-ran; echo converted"]); + yield* git(cwd, ["config", "color.ui", "always"]); + yield* git(cwd, ["config", "diff.relative", "true"]); + yield* git(cwd, ["config", "log.decorate", "short"]); yield* git(cwd, ["checkout", "-b", "feature/range"]); yield* writeTextFile(cwd, ".gitattributes", "*.doc diff=probe\n"); yield* writeTextFile(cwd, "notes.doc", "source line\n"); yield* writeTextFile(cwd, "plain.txt", "plain line\n"); yield* writeTextFile(cwd, "binary.dat", "binary\0data"); yield* git(cwd, ["add", "."]); - yield* git(cwd, ["commit", "-m", "range files"]); + const signingKey = `${yield* makeTmpDir("git-vcs-driver-signing-key-")}/key`; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const keygen = ChildProcess.make("ssh-keygen", [ + "-q", + "-t", + "ed25519", + "-N", + "", + "-f", + signingKey, + ]); + assert.equal(Number(yield* spawner.exitCode(keygen)), 0); + yield* git(cwd, ["config", "gpg.format", "ssh"]); + // A global signer such as 1Password's cannot use the fixture's generated key. + yield* git(cwd, ["config", "gpg.ssh.program", "ssh-keygen"]); + yield* git(cwd, ["config", "user.signingkey", `${signingKey}.pub`]); + yield* git(cwd, ["config", "log.showSignature", "true"]); + yield* git(cwd, ["commit", "-S", "-m", "range files"]); const driver = yield* GitVcsDriver.GitVcsDriver; const fileSystem = yield* FileSystem.FileSystem; + const projectCwd = `${cwd}/app`; + yield* fileSystem.makeDirectory(projectCwd); - const context = yield* driver.readRangeContext(cwd, initialBranch); + const context = yield* driver.readRangeContext(projectCwd, initialBranch); + assert.match(context.commitSummary.trim(), /^[a-f0-9]+ range files$/); assert.include(context.diffPatch, "+source line"); assert.notInclude(context.diffPatch, "converted"); assert.include(context.diffPatch, "+plain line"); assert.include(context.diffPatch, "Binary files /dev/null and b/binary.dat differ"); assert.include(context.diffSummary, "notes.doc"); + for (const output of [context.commitSummary, context.diffSummary, context.diffPatch]) { + assert.notInclude(output, "\u001b["); + } assert.isFalse(yield* fileSystem.exists(`${cwd}/textconv-ran`)); yield* writeTextFile(cwd, "notes.doc", "updated source line\n"); - const staged = yield* driver.prepareCommitContext(cwd, ["notes.doc"]); + const staged = yield* driver.prepareCommitContext(projectCwd, ["notes.doc"]); assert.include(staged?.stagedPatch ?? "", "+updated source line"); assert.notInclude(staged?.stagedPatch ?? "", "converted"); + assert.notInclude(staged?.stagedPatch ?? "", "\u001b["); assert.include(staged?.stagedSummary ?? "", "notes.doc"); assert.isFalse(yield* fileSystem.exists(`${cwd}/textconv-ran`)); }), @@ -2883,6 +3309,70 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("does not guess a Changes base for a branch renamed during the status read", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const fork = yield* makeTmpDir(); + const upstream = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(fork, ["init", "--bare"]); + yield* git(upstream, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", fork]); + yield* git(cwd, ["remote", "add", "upstream", upstream]); + yield* git(cwd, ["push", "origin", "main"]); + yield* writeTextFile(cwd, "upstream.txt", "work missing from the fork\n"); + yield* git(cwd, ["add", "upstream.txt"]); + yield* git(cwd, ["commit", "-m", "upstream work"]); + yield* git(cwd, ["push", "upstream", "main"]); + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["fetch", "upstream"]); + yield* git(cwd, ["checkout", "-b", "t3/temporary"]); + yield* git(cwd, ["config", "branch.t3/temporary.t3-merge-base", "main"]); + yield* git(cwd, [ + "config", + "branch.t3/temporary.t3-merge-base-ref", + "refs/remotes/upstream/main", + ]); + yield* writeTextFile(cwd, "feature.txt", "thread work\n"); + + const outer = yield* GitVcsDriver.GitVcsDriver; + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const renameClaimed = yield* Ref.make(false); + const renamed = yield* Deferred.make(); + // Rename after `git status` has read HEAD, as the thread launcher does after the first turn. + // Base config reads start only after status completes; hold every one until the rename lands. + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (ChildProcess.isStandardCommand(command) && command.args[0] === "config") { + if (!(yield* Ref.getAndSet(renameClaimed, true))) { + yield* git(cwd, ["branch", "-m", "t3/temporary", "feature/named"]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, outer), + Effect.orDie, + ); + yield* Deferred.succeed(renamed, undefined); + } + yield* Deferred.await(renamed); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(layerServerConfig), + ); + + const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); + + assert.strictEqual(status.branch, "t3/temporary"); + assert.deepStrictEqual(status.branchChanges, { + baseRef: null, + insertions: 1, + deletions: 0, + }); + }), + ); + it.effect("keeps local branch bases consistent when tags share their names", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -3029,16 +3519,24 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("repository status", () => { - it.effect("reads the current branch by name when a tag shares it", () => + it.effect("reads the current branch and upstream by name when other refs share them", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); yield* git(cwd, ["checkout", "-b", "release"]); + yield* git(cwd, ["push", "-u", "origin", "release"]); + yield* git(cwd, ["commit", "--allow-empty", "-m", "unpushed"]); + yield* git(cwd, ["branch", "origin/release"]); yield* git(cwd, ["tag", "release"]); const driver = yield* GitVcsDriver.GitVcsDriver; const remoteStatus = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); assert.equal(remoteStatus.branch, "release"); + assert.equal(remoteStatus.upstreamRef, "origin/release"); + assert.equal(remoteStatus.aheadCount, 1); const preview = yield* driver.getReviewDiffPreview({ cwd }); const branchRange = preview.sources.find((source) => source.kind === "branch-range"); assert.equal(branchRange?.headRef, "release"); @@ -3253,7 +3751,9 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { command.args.includes("ls-files") && command.args.includes("--others") ) { - untrackedScanRoots.push(command.args.slice(command.args.indexOf("--") + 1)); + untrackedScanRoots.push( + command.args.slice(command.args.indexOf("--") + 1).map(String), + ); } return delegate.spawn(command); }); @@ -3567,30 +4067,12 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ].join("\n"), ); yield* fileSystem.chmod(sshWrapperPath, 0o755); - const envKeys = [ - "GCM_INTERACTIVE", - "GIT_ASKPASS", - "GIT_SSH", - "GIT_TERMINAL_PROMPT", - "SSH_ASKPASS", - "SSH_ASKPASS_REQUIRE", - "T3_TEST_SSH_ASKPASS_LOG", - ] as const; - const previousEnv = new Map(envKeys.map((key) => [key, process.env[key]])); yield* git(cwd, ["remote", "add", "origin", "ssh://example.invalid/repo.git"]); yield* git(cwd, ["update-ref", `refs/remotes/origin/${initialBranch}`, "HEAD"]); yield* git(cwd, ["branch", "--set-upstream-to", `origin/${initialBranch}`]); yield* Effect.gen(function* () { - process.env.GIT_SSH = sshWrapperPath; - process.env.GCM_INTERACTIVE = "always"; - process.env.GIT_ASKPASS = "git-askpass"; - process.env.GIT_TERMINAL_PROMPT = "1"; - process.env.SSH_ASKPASS = "ssh-askpass"; - process.env.SSH_ASKPASS_REQUIRE = "force"; - process.env.T3_TEST_SSH_ASKPASS_LOG = sshLogPath; - yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); assert.deepEqual((yield* fileSystem.readFileString(sshLogPath)).trim().split(/\r?\n/), [ @@ -3601,18 +4083,16 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { "SSH_ASKPASS_REQUIRE=never", ]); }).pipe( - Effect.ensuring( - Effect.sync(() => { - for (const key of envKeys) { - const previous = previousEnv.get(key); - if (previous === undefined) { - delete process.env[key]; - } else { - process.env[key] = previous; - } - } - }), - ), + Effect.provideService(HostProcess.Environment, { + ...(yield* HostProcess.Environment), + GIT_SSH: sshWrapperPath, + GCM_INTERACTIVE: "always", + GIT_ASKPASS: "git-askpass", + GIT_TERMINAL_PROMPT: "1", + SSH_ASKPASS: "ssh-askpass", + SSH_ASKPASS_REQUIRE: "force", + T3_TEST_SSH_ASKPASS_LOG: sshLogPath, + }), ); }), ); @@ -4006,6 +4486,38 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { } }), ); + + // regression: #27749 hooks inherited the driver's forced LC_ALL=C + it.effect("runs post-checkout hooks under the server's own locale", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const serverLocale = (yield* HostProcess.Environment).LC_ALL ?? "unset"; + const seenLocale = path.join(yield* makeTmpDir("git-hook-locale-"), "lc-all"); + yield* writeTextFile( + cwd, + "hooks/post-checkout", + `#!/bin/sh\necho "\${LC_ALL-unset}" > '${seenLocale}'\n[ "\${LC_ALL-unset}" = '${serverLocale}' ]\n`, + ); + yield* fs.chmod(path.join(cwd, "hooks/post-checkout"), 0o755); + yield* git(cwd, ["config", "core.hooksPath", path.join(cwd, "hooks")]); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "locale"); + + const result = yield* driver + .createWorktree({ cwd, path: worktreePath, refName: initialBranch, newRefName: "locale" }) + .pipe(Effect.result); + + assert.equal( + (yield* fs.readFileString(seenLocale)).trim(), + serverLocale, + "post-checkout hook should see the server's LC_ALL", + ); + assert.isTrue(Result.isSuccess(result), "worktree creation failed in the hook"); + }), + ); it.effect.each(["commit", "short-commit", "tag", "revision"] as const)( "creates a worktree from a %s without recording it as a PR base", (kind) => @@ -4041,6 +4553,38 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + // regression: #28354 a failed worktree add left its new branch behind and blocked retries + it.effect("removes the new branch when the worktree cannot be added", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const path = yield* Path.Path; + const occupiedPath = yield* makeTmpDir("git-worktrees-"); + yield* writeTextFile(occupiedPath, "existing.txt", "occupied\n"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const failed = yield* driver + .createWorktree({ + cwd, + path: occupiedPath, + refName: initialBranch, + newRefName: "t3/collide", + }) + .pipe(Effect.result); + + assert.isTrue(Result.isFailure(failed)); + assert.equal(yield* git(cwd, ["branch", "--list", "t3/collide"]), ""); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "retry"); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "t3/collide", + }); + assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "t3/collide"); + }), + ); + it.effect("creates parallel worktrees in one repository without racing on its config", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -4059,7 +4603,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const countingSpawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { - const args = ChildProcess.isStandardCommand(command) ? command.args : []; + const args = ChildProcess.isStandardCommand(command) ? command.args.map(String) : []; const writesConfig = (args.includes("worktree") && args.includes("add") && args.includes("-b")) || (args[0] === "branch" && @@ -4230,19 +4774,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - // Git blocks file submodule transports (CVE-2022-39253) despite repo config; local fixtures need env permission. - const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; - process.env.GIT_ALLOW_PROTOCOL = "file"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previousAllowedProtocol === undefined) { - delete process.env.GIT_ALLOW_PROTOCOL; - } else { - process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; - } - }), - ); - const submoduleRepo = yield* makeTmpDir("git-submodule-"); yield* initRepoWithCommit(submoduleRepo); yield* writeTextFile(submoduleRepo, ".gitattributes", "* text eol=lf\n"); @@ -4328,7 +4859,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { "", ); if (progress) assert.isNotEmpty(yield* Ref.get(lines)); - }), + }).pipe(withGitFileProtocol), ); it.effect("still creates the worktree when submodule checkout fails", () => @@ -4457,18 +4988,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; - process.env.GIT_ALLOW_PROTOCOL = "file"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previousAllowedProtocol === undefined) { - delete process.env.GIT_ALLOW_PROTOCOL; - } else { - process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; - } - }), - ); - const nestedRepo = yield* makeTmpDir("git-nested-"); yield* initRepoWithCommit(nestedRepo); yield* writeTextFile(nestedRepo, "NESTED.md", "# nested\n"); @@ -4539,7 +5058,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { inner: false, nested: false, }); - }), + }).pipe(withGitFileProtocol), ); it.effect("still creates the worktree when the base-ref config stays locked", () => @@ -4820,7 +5339,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const spawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { - const args = ChildProcess.isStandardCommand(command) ? command.args : []; + const args = ChildProcess.isStandardCommand(command) ? command.args.map(String) : []; if (args[0] === "remote" && args[1] === "-v") { listings += 1; if (listings === 2) yield* Deferred.succeed(bothListed, undefined); @@ -6035,7 +6554,7 @@ describe("Windows long path configuration", () => { ) { const layer = GitVcsDriver.layer.pipe( Layer.provide(layerServerConfig), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provide(Layer.succeed(HostProcess.Platform, platform)), ); return yield* Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 1cf5ca52effee..c63beb12936b9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,8 +1,7 @@ import * as Cache from "effect/Cache"; -import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -41,7 +40,7 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; import * as GitVcsDriver from "./GitVcsDriver.ts"; -import { removeEmptyDirectory } from "./removeEmptyDirectory.ts"; +import { removeEmptyDirectory, type RemoveEmptyDirectoryError } from "./removeEmptyDirectory.ts"; import { explicitBareRepositoryEnv, gitConfigEnv } from "./VcsProcess.ts"; import { removeLinkedWorktree } from "./worktreeRemoval.ts"; import { resolveWorktreesDirectory } from "../worktreesDirectory.ts"; @@ -81,12 +80,16 @@ const REVIEW_BRANCH_SUBJECT_LIMIT = 300; const REVIEW_BRANCH_COMMIT_MAX_OUTPUT_BYTES = 128 * 1024; // Client patch parsers require a/ and b/ prefixes; override user diff prefix settings. export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; +// Client patch parsers drop blank context lines under diff.suppressBlankEmpty and summaries under diff.submodule. const REVIEW_DIFF_ARGS = [ + "-c", + "diff.suppressBlankEmpty=false", "diff", "--find-renames", "--no-color", "--no-ext-diff", "--no-textconv", + "--submodule=short", ...PATCH_RENDER_PREFIX_ARGS, ]; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); @@ -934,7 +937,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { worktreesDir } = yield* ServerConfig.ServerConfig; const isServerInternalPath = yield* makeIsServerInternalPath; - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const hostPlatform = yield* HostProcess.Platform; const serviceScope = yield* Effect.scope; const statusFetches = new Map>(); @@ -964,7 +967,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); const baseEnv = { - ...process.env, + ...(yield* HostProcess.Environment), // Concurrent status polls must not take index.lock to save refreshed indexes. GIT_OPTIONAL_LOCKS: "0", ...input.env, @@ -1170,7 +1173,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...options, env: { ...options.env, - LC_ALL: "C", + // English diagnostics without overriding LC_CTYPE, which hooks inherit. + LANGUAGE: "C", }, }); @@ -1280,13 +1284,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); }); + // `--abbrev-ref` prints `remotes/origin/` when a local `origin/` branch exists. const readCurrentUpstreamRef = Effect.fn("readCurrentUpstreamRef")(function* (cwd: string) { const upstreamRef = yield* runGitStdout( "GitVcsDriver.resolveCurrentUpstream", cwd, - ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + ["rev-parse", "--symbolic-full-name", "@{upstream}"], true, - ).pipe(Effect.map((stdout) => stdout.trim())); + ).pipe(Effect.map((stdout) => stdout.trim().replace(/^refs\/(?:remotes|heads)\//, ""))); if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { return null; @@ -1418,22 +1423,33 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* .pipe(Effect.orElseSucceed(() => resolvedGitCommonDir)); }); + const readWorktreeRoot = Effect.fn("readWorktreeRoot")(function* (cwd: string) { + const result = yield* executeGit( + "GitVcsDriver.resolveRepositoryPaths.worktreeRoot", + cwd, + ["rev-parse", "--show-toplevel"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ); + // Git terminates --show-toplevel with exactly one "\n" on every platform (stdout is + // binary mode on Windows). Whitespace before it belongs to the path and must survive, + // because this root becomes the editor target for every repository-relative status path. + const output = result.stdout.endsWith("\n") ? result.stdout.slice(0, -1) : result.stdout; + return result.exitCode === 0 && output.length > 0 + ? path.normalize(path.isAbsolute(output) ? output : path.resolve(cwd, output)) + : null; + }); + const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( cwd: string, ) { const gitCommonDir = yield* readGitCommonDir(cwd); if (gitCommonDir === null) return null; - const [worktreeRootResult, currentBranchResult] = yield* Effect.all( + const [worktreeRoot, currentBranchResult] = yield* Effect.all( [ - executeGit( - "GitVcsDriver.resolveRepositoryPaths.worktreeRoot", - cwd, - ["rev-parse", "--show-toplevel"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), + readWorktreeRoot(cwd), executeGit( "GitVcsDriver.resolveRepositoryPaths.currentBranch", cwd, @@ -1446,20 +1462,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { concurrency: 2 }, ); - // Git terminates --show-toplevel with exactly one "\n" on every platform (stdout is - // binary mode on Windows). Whitespace before it belongs to the path and must survive, - // because this root becomes the editor target for every repository-relative status path. - const worktreeRootOutput = worktreeRootResult.stdout.endsWith("\n") - ? worktreeRootResult.stdout.slice(0, -1) - : worktreeRootResult.stdout; - const worktreeRoot = - worktreeRootResult.exitCode === 0 && worktreeRootOutput.length > 0 - ? path.normalize( - path.isAbsolute(worktreeRootOutput) - ? worktreeRootOutput - : path.resolve(cwd, worktreeRootOutput), - ) - : null; // Git reports physical roots, so resolve project aliases before comparing. const isAtRepositoryRoot = worktreeRoot === null @@ -1479,6 +1481,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } satisfies GitRepositoryPaths; }); + const resolveReviewWorktreeRoot = Effect.fn("resolveReviewWorktreeRoot")(function* (cwd: string) { + const worktreeRoot = yield* readWorktreeRoot(cwd); + if (worktreeRoot !== null) return { worktreeRoot, currentBranch: null }; + // Full discovery tells bare and non-repository directories apart from real Git failures. + return yield* resolveRepositoryPathsUncached(cwd); + }); + const repositoryPathsCache = yield* Cache.makeWith( (cwd: string) => resolveRepositoryPathsUncached(cwd), { @@ -1630,15 +1639,25 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( cwd: string, ) { - const upstream = yield* resolveCurrentUpstream(cwd); - if (!upstream) return; + const headRef = yield* runGitStdout( + "GitVcsDriver.refreshStatusUpstreamIfStale.headRef", + cwd, + ["symbolic-ref", "--quiet", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (headRef.length === 0) return; + const remoteName = yield* runGitStdout( + "GitVcsDriver.refreshStatusUpstreamIfStale.upstreamRemote", + cwd, + ["for-each-ref", "--format=%(upstream:remotename)", headRef], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + // Empty means no upstream; `.` means the upstream is a local branch. + if (remoteName.length === 0 || remoteName === ".") return; const gitCommonDir = yield* resolveGitCommonDir(cwd); yield* Cache.get( statusRemoteRefreshCache, - new StatusRemoteRefreshCacheKey({ - gitCommonDir, - remoteName: upstream.remoteName, - }), + new StatusRemoteRefreshCacheKey({ gitCommonDir, remoteName }), ).pipe(Effect.ignore); }); @@ -2019,7 +2038,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const divergence = yield* executeGit( "GitVcsDriver.statusDetailsRemote.divergence", cwd, - ["rev-list", "--left-right", "--count", `HEAD...${upstreamRef}`], + ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], { allowNonZeroExit: true }, ); if (divergence.exitCode === 0) { @@ -2503,7 +2522,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const stagedSummary = yield* runGitStdoutWithOptions( "GitVcsDriver.prepareCommitContext.stagedSummary", cwd, - ["diff", "--cached", "--name-status"], + // cwd can be a subdirectory; diff.relative would hide changes outside it. + ["-c", "diff.relative=false", "diff", "--cached", "--name-status"], { env, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ).pipe(Effect.map((stdout) => stdout.trim())); if (stagedSummary.length === 0) { @@ -2516,7 +2536,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const stagedPatch = yield* runGitStdoutWithOptions( "GitVcsDriver.prepareCommitContext.stagedPatch", cwd, - ["diff", "--no-ext-diff", "--no-textconv", "--cached", "--patch", "--minimal"], + [ + "-c", + "diff.relative=false", + "diff", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--cached", + "--patch", + "--minimal", + ], { env, maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, @@ -2909,7 +2939,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.log", cwd, - ["log", "--oneline", "--end-of-options", commitRange], + [ + "log", + "--oneline", + "--no-color", + "--no-decorate", + "--no-show-signature", + "--end-of-options", + commitRange, + ], { maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2918,7 +2956,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffStat", cwd, - ["diff", "--stat", "--end-of-options", diffRange], + [ + "-c", + "diff.relative=false", + "diff", + "--stat", + "--no-color", + "--end-of-options", + diffRange, + ], { maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2928,7 +2974,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.readRangeContext.diffPatch", cwd, [ + "-c", + "diff.relative=false", "diff", + "--no-color", "--no-ext-diff", "--no-textconv", "--patch", @@ -2961,7 +3010,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.stagedDeletions", cwd, - ["diff", "--cached", "--name-only", "--diff-filter=D", "-z", "HEAD", "--"], + [ + "diff", + "--cached", + "--no-renames", + "--name-only", + "--diff-filter=D", + "-z", + "HEAD", + "--", + ], { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ), runGitStdout("GitVcsDriver.readUnifiedWorkingTreeReviewDiff.indexPath", cwd, [ @@ -3016,6 +3074,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // Avoid redundant tracked-entry pathspec matching in this disposable index. "-c", "core.preloadIndex=false", + // Sparse checkout refuses to add untracked files outside the cone, failing the whole review. + "-c", + "core.sparseCheckout=false", "--literal-pathspecs", "add", "--intent-to-add", @@ -3047,15 +3108,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...pathArgs], { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, - ).pipe( - Effect.catchIf( - (error) => error.outputLength === undefined, - () => Effect.succeed(null), - ), - ); + ).pipe(Effect.orElseSucceed(() => null)); if (untracked === null) return null; + // ls-files lists a nested repository with a trailing slash; its diff path has none. const paths = splitNullSeparatedGitStdoutPaths(untracked).filter( - (candidate) => onlyPath === undefined || candidate === onlyPath, + (candidate) => + onlyPath === undefined || candidate === onlyPath || candidate === `${onlyPath}/`, ); if (paths.length === 0) return { env: undefined }; if (paths.length > REVIEW_UNTRACKED_MAX_FILES) return null; @@ -3131,12 +3189,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* readonly untrackedRoots: ReadonlyArray; }, ) { - const { baseRef, mergeBase } = yield* resolveReviewMergeBase( + const resolved = yield* resolveReviewMergeBase( cwd, branch, undefined, options?.knownOriginDefaultBranch ?? null, ); + // A rename after `git status` read HEAD moves the base config away, so the base was guessed. + const { baseRef, mergeBase } = + branch === null || (yield* branchExists(cwd, branch)) + ? resolved + : { baseRef: null, mergeBase: "HEAD" }; const roots = options?.untrackedRoots ?? []; // Bound pathspec matching work and stay below Windows' command-line limit. const pathspecs = @@ -3332,7 +3395,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const patchLimit = input.file ? REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES : REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES; - const repository = yield* resolveRepositoryPathsUncached(input.cwd).pipe( + const sourceKind = input.file?.sourceKind ?? input.sourceKind; + const needsCurrentBranch = + input.commitSha === undefined && + input.file?.sourceKind !== "commit" && + sourceKind !== "working-tree"; + const repository = yield* ( + needsCurrentBranch + ? resolveRepositoryPathsUncached(input.cwd) + : resolveReviewWorktreeRoot(input.cwd) + ).pipe( Effect.catchTags({ GitCommandError: (error) => isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), @@ -3348,7 +3420,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const cwd = repository.worktreeRoot; const hashDiff = (diff: string, files: ReadonlyArray) => - crypto.digest("SHA-256", new TextEncoder().encode(JSON.stringify([diff, files]))).pipe( + digest.digest(Digest.sha256, new TextEncoder().encode(JSON.stringify([diff, files]))).pipe( Effect.map(Hex.encode), Effect.mapError( (cause) => @@ -3378,12 +3450,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ); const files = parseReviewNumstat(stat.stdout); - const patch = yield* executeGit( - "GitVcsDriver.getReviewDiffPreview.commitPatch", - cwd, - [...diffArgs, "--patch", commit.parent, commit.sha, "--", ...pathArgs], - { maxOutputBytes: patchLimit, appendTruncationMarker: true }, - ); + const patch = + input.metadataOnly && !input.file + ? { stdout: "", stdoutTruncated: false } + : yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.commitPatch", + cwd, + [...diffArgs, "--patch", commit.parent, commit.sha, "--", ...pathArgs], + { maxOutputBytes: patchLimit, appendTruncationMarker: true }, + ); const diffHash = yield* hashDiff(patch.stdout, files); return { cwd: input.cwd, @@ -3400,13 +3475,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* files, diffHash, truncated: patch.stdoutTruncated, + ...(input.metadataOnly && !input.file ? { patchesDeferred: true } : {}), }, ], }; } - // Coder: thread switching can request only one review source. - const sourceKind = input.file?.sourceKind ?? input.sourceKind; const dirtyRef = sourceKind === "branch-range" ? null : "HEAD"; const review = sourceKind === "working-tree" @@ -3451,10 +3525,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const readTrackedDiff = Effect.fn("GitVcsDriver.getReviewDiffPreview.tracked")(function* ( ref: string | null, env?: NodeJS.ProcessEnv, + metadataOnly = false, ) { if (ref === null) return { stdout: "", stdoutTruncated: false, files: [] }; const stat = yield* readStats(ref, env); - if (stat.files.length === 0) return { stdout: "", stdoutTruncated: false, files: [] }; + if (metadataOnly || stat.files.length === 0) { + return { stdout: "", stdoutTruncated: false, files: stat.files }; + } const patch = yield* executeGit( "GitVcsDriver.getReviewDiffPreview.patch", cwd, @@ -3464,16 +3541,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { ...patch, files: stat.files }; }); const [dirtyTrackedResult, baseResult] = yield* Effect.gen(function* () { - const untracked = yield* prepareUntrackedReviewIndex(cwd, pathArgs, input.file?.path); + const untracked = input.trackedOnly + ? { env: undefined } + : yield* prepareUntrackedReviewIndex(cwd, pathArgs, input.file?.path); + const metadataOnly = input.metadataOnly === true && !input.file && untracked !== null; const [dirty, base] = review.mergeBase === dirtyRef - ? yield* readTrackedDiff(dirtyRef, untracked?.env).pipe( + ? yield* readTrackedDiff(dirtyRef, untracked?.env, metadataOnly).pipe( Effect.map((result) => [result, result] as const), ) : yield* Effect.all( [ - readTrackedDiff(dirtyRef, untracked?.env), - readTrackedDiff(review.mergeBase, untracked?.env), + readTrackedDiff(dirtyRef, untracked?.env, metadataOnly), + readTrackedDiff(review.mergeBase, untracked?.env, metadataOnly), ], { concurrency: 2 }, ); @@ -3502,6 +3582,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...(dirtyFiles === undefined ? {} : { files: dirtyFiles }), diffHash: dirtyDiffHash, truncated: dirtyTrackedResult.stdoutTruncated, + ...(input.metadataOnly && !input.file && dirtyFiles !== undefined + ? { patchesDeferred: true } + : {}), }, { id: "branch-range", @@ -3513,16 +3596,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...(baseFiles === undefined ? {} : { files: baseFiles }), diffHash: baseDiffHash, truncated: baseResult.stdoutTruncated, + ...(input.metadataOnly && !input.file && baseFiles !== undefined + ? { patchesDeferred: true } + : {}), }, ]; return { cwd: input.cwd, generatedAt: yield* DateTime.now, - // Coder: a top-level sourceKind (thread switching) returns only that source. sources: input.sourceKind ? sources.filter((source) => source.kind === input.sourceKind) : sources, + ...(input.trackedOnly ? { incomplete: true } : {}), }; }); @@ -4192,24 +4278,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* let directoryName = targetBranch.replace(/\//g, "-"); // Leave room for files beneath the worktree on Windows, regardless of branch length. if (directoryName.length > 40) { - const digest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(targetBranch)) - .pipe(Effect.orDie); + const hash = yield* digest.digest(Digest.sha256, new TextEncoder().encode(targetBranch)); // Avoid splitting a surrogate pair, which Git would list back as a different path. const prefix = directoryName.slice(0, 23).replace(/[\uD800-\uDBFF]$/, ""); - directoryName = `${prefix}-${Hex.encode(digest).slice(0, 16)}`; + directoryName = `${prefix}-${Hex.encode(hash).slice(0, 16)}`; } worktreePath = path.join(parentDir, repoName, directoryName); } - if (input.newRefName) { + const newRefName = input.newRefName; + if (newRefName) { // Kept apart from the checkout so the shared-config lock only covers the tracking write. yield* createBranchAt( "GitVcsDriver.createWorktree.createBranch", - { cwd: input.cwd, branch: input.newRefName, startPoint: input.refName }, + { cwd: input.cwd, branch: newRefName, startPoint: input.refName }, "git worktree add failed", ); } - const args = ["worktree", "add", "--", worktreePath, input.newRefName ?? input.refName]; + const args = ["worktree", "add", "--", worktreePath, newRefName ?? input.refName]; const progress = options?.progress; const onCheckoutProgress = progress?.onCheckoutProgress; @@ -4247,6 +4332,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } : {}), }, + ).pipe( + Effect.onError(() => + newRefName + ? deleteLocalBranch({ cwd: input.cwd, refName: newRefName, force: true }).pipe( + Effect.catch((cause) => + Effect.logWarning("could not remove the branch of a failed worktree", { cause }), + ), + ) + : Effect.void, + ), ); if (progress?.onWorktreeClaimed) { @@ -4296,7 +4391,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* timeoutMs: null, ...(onSubmoduleLine ? { - env: { LC_ALL: "C" }, + env: { LANGUAGE: "C" }, progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, } : {}), @@ -4323,7 +4418,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } - const newRefName = input.newRefName; if (newRefName && base) { yield* Effect.gen(function* () { yield* writeConfigValue({ @@ -4673,7 +4767,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // Recursive filesystem traversal can visit many entries; removeWorktree owns the operation span. const removeLeftoverLinks = Effect.fnUntraced(function* ( target: string, - ): Effect.fn.Return { + ): Effect.fn.Return { if (Option.isSome(yield* fileSystem.readLink(target).pipe(Effect.option))) { yield* fileSystem.remove(target); return true; diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index b6f7dae813744..c13d44f2d813a 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -1,4 +1,5 @@ import { assert, it, describe } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -26,7 +27,7 @@ const DETECT_COMMAND = "rev-parse --is-inside-work-tree --show-toplevel --git-co describe("VcsDriverRegistry", () => { const makeDiskBackedLayer = (rootDir: string, calls: string[]) => Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provide( Layer.mock(VcsProjectConfig.VcsProjectConfig)({ resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), @@ -57,7 +58,10 @@ describe("VcsDriverRegistry", () => { ); } return processOutput(""); - }).pipe(Effect.provide(NodeServices.layer), Effect.orDie), + }).pipe( + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + Effect.orDie, + ), }), ), ); @@ -79,7 +83,7 @@ describe("VcsDriverRegistry", () => { assert.equal(yield* registry.detect({ cwd: repoDir }), null); assert.equal(calls.filter((call) => call === DETECT_COMMAND).length, 2); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("detects again when git init creates a repository inside a cached one", () => @@ -103,7 +107,7 @@ describe("VcsDriverRegistry", () => { projectDir, ); }).pipe(Effect.provide(makeDiskBackedLayer(parentDir, calls))); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("detects a repository created after a negative lookup", () => @@ -122,6 +126,6 @@ describe("VcsDriverRegistry", () => { assert.equal((yield* registry.detect({ cwd: repoDir }))?.repository.rootPath, repoDir); assert.equal(calls.filter((call) => call === DETECT_COMMAND).length, 2); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 212415171bdc6..199ded803ee18 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -8,10 +8,12 @@ import * as Cause from "effect/Cause"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/process"; import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; @@ -316,6 +318,60 @@ describe("VcsProcess.run", () => { }), ); + it.effect("leaves git a slot while slow forge CLI processes are running", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + const service = yield* VcsProcess.make.pipe( + Effect.provideService( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.gen(function* () { + if (input.command !== "git") yield* Deferred.await(gate); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }), + ), + ); + + const forgeRuns = yield* Effect.forEach(Array.from({ length: 12 }), () => + service + .run({ + operation: "synthetic.forge.listChangeRequests", + command: "tea", + args: ["pulls", "list"], + cwd: "/workspace", + }) + .pipe(Effect.forkChild({ startImmediately: true })), + ); + const checkpoint = yield* service + .run({ + ...baseInput, + operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION, + args: ["rev-parse", "HEAD"], + }) + .pipe(Effect.timeoutOption("1 second"), Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + + const gitResult = yield* Fiber.join(checkpoint); + yield* Deferred.succeed(gate, undefined); + yield* Fiber.joinAll(forgeRuns); + expect( + Option.isSome(gitResult), + "git waited behind forge CLI runs that held every VCS process slot", + ).toBe(true); + }), + ); + it.effect("keeps git from opening a folder laid out as a bare repository", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 164850a44a48d..9ebe50808e33d 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -64,6 +64,8 @@ const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const VCS_PROCESS_CONCURRENCY = 8; +// Forge CLIs can take seconds per call; cap them together so git, checkpoints included, keeps slots. +const NON_GIT_PROCESS_CONCURRENCY = VCS_PROCESS_CONCURRENCY / 2; /** Shown to users when a Git command fails because another process holds the index lock. */ export const GIT_INDEX_LOCKED_DETAIL = @@ -162,6 +164,7 @@ export const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; const hostPlatform = yield* HostProcess.Platform; const vcsProcesses = yield* Semaphore.make(VCS_PROCESS_CONCURRENCY); + const nonGitProcesses = yield* Semaphore.make(NON_GIT_PROCESS_CONCURRENCY); const keyedProcesses = new Map(); const keyedPermit = (concurrency: NonNullable) => { let semaphore = keyedProcesses.get(concurrency.key); @@ -325,9 +328,8 @@ export const make = Effect.gen(function* () { }), ); } - return yield* input.concurrency === undefined - ? bounded - : keyedPermit(input.concurrency)(bounded); + const shared = input.command === "git" ? bounded : nonGitProcesses.withPermits(1)(bounded); + return yield* input.concurrency === undefined ? shared : keyedPermit(input.concurrency)(shared); }); return VcsProcess.of({ run }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index e1a9069d51774..9ff5b891e037f 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -371,13 +371,14 @@ describe("VcsStatusBroadcaster", () => { ); it.effect("refreshes a loaded cwd without reusing a previous branch's PR", () => { - const state = { + const state: Parameters[0] = { currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, + currentRemoteStatus: { ...baseRemoteStatus, aheadCount: 3, behindCount: 5 }, localStatusCalls: 0, remoteStatusCalls: 0, localInvalidationCalls: 0, remoteInvalidationCalls: 0, + remoteStatusRefreshUpstreamValues: [], }; return Effect.gen(function* () { @@ -389,9 +390,14 @@ describe("VcsStatusBroadcaster", () => { yield* broadcaster.getStatus({ cwd: "/repo" }); assert.equal(state.remoteStatusCalls, 1); - state.currentRemoteStatus = remoteStatusWithPr; + state.backgroundWorkEnabled = false; + state.currentRemoteStatus = { ...remoteStatusWithPr, aheadCount: 7 }; const refreshed = yield* broadcaster.refreshPullRequestStatus("/repo"); - assert.deepStrictEqual(refreshed, remoteStatusWithPr); + assert.deepStrictEqual(refreshed, state.currentRemoteStatus); + const merged = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(merged.aheadCount, 7); + assert.equal(merged.behindCount, 0); + assert.equal(state.remoteStatusRefreshUpstreamValues?.at(-1), false); assert.equal(state.remoteStatusCalls, 2); assert.equal(state.remoteInvalidationCalls, 2); @@ -973,7 +979,7 @@ describe("VcsStatusBroadcaster", () => { }, ); - it.effect("recovers a discarded branch lookup on the next permitted turn-end refresh", () => { + it.effect("recovers a discarded branch lookup on the next turn-end refresh", () => { const state: Parameters[0] = { currentLocalStatus: baseLocalStatus, currentRemoteStatus: { ...remoteStatusWithPr, aheadCount: 3, behindCount: 5 }, @@ -1012,9 +1018,6 @@ describe("VcsStatusBroadcaster", () => { pr: { ...remoteStatusWithPr.pr!, number: 2979, headRef: "feature/next-status" }, }; state.backgroundWorkEnabled = false; - yield* broadcaster.refreshPullRequestStatus("/repo"); - assert.equal(state.remoteStatusCalls, 1); - state.backgroundWorkEnabled = true; const recovered = yield* broadcaster.refreshPullRequestStatus("/repo"); assert.deepStrictEqual(recovered, state.currentRemoteStatus); const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); @@ -1297,12 +1300,10 @@ describe("Coder auto-pull policy", () => { Layer.provide( Layer.mock(ProjectStore.ProjectStoreV2)({ findActiveByWorkspaceRoot: () => - Effect.succeed( - Option.some({ - projectId, - autoPull: testCase.autoPull, - } as ProjectStore.ProjectRow), - ), + Effect.succeedSome({ + projectId, + autoPull: testCase.autoPull, + } as ProjectStore.ProjectRow), }), ), Layer.provide( diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index b9d40058051ac..f2023ca5711ee 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -550,15 +550,7 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const cached = yield* getCachedStatus(cwd); if (!cached?.local || cached.remote?.value === null) return null; - const poller = (yield* SynchronizedRef.get(pollersRef)).get(cwd); - const demandCwds = poller ? [...(yield* Ref.get(poller.demandCwds)).keys()] : [rawCwd]; - const shouldRefresh = (yield* Effect.forEach( - demandCwds, - (demandCwd) => - backgroundPolicy.shouldRunScopeWork({ type: "vcs-status", cwd: demandCwd }), - { concurrency: "unbounded" }, - )).some(Boolean); - if (!shouldRefresh) return null; + // Turn completion must update divergence even when background polling is paused. const remoteRead = yield* readRemoteStatus(cwd, { refreshUpstream: false, refreshMissingPullRequest: true, diff --git a/apps/server/src/vcs/removeEmptyDirectory.ts b/apps/server/src/vcs/removeEmptyDirectory.ts index 2ce80dfe588a2..f7c8348af8db6 100644 --- a/apps/server/src/vcs/removeEmptyDirectory.ts +++ b/apps/server/src/vcs/removeEmptyDirectory.ts @@ -2,6 +2,16 @@ import * as NodeFSP from "node:fs/promises"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export class RemoveEmptyDirectoryError extends Schema.TaggedError()( + "RemoveEmptyDirectoryError", + { path: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not remove the directory ${this.path}.`; + } +} /** * Removes `path` only while it is an empty directory. Succeeds with false when @@ -9,7 +19,10 @@ import * as Effect from "effect/Effect"; * Other failures, such as a permission error, stay errors. */ export const removeEmptyDirectory = (path: string) => - Effect.tryPromise(() => NodeFSP.rmdir(path)).pipe( + Effect.tryPromise({ + try: () => NodeFSP.rmdir(path), + catch: (cause) => new RemoveEmptyDirectoryError({ path, cause }), + }).pipe( Effect.as(true), Effect.catchIf( (error) => (error.cause as NodeJS.ErrnoException | undefined)?.code === "ENOTEMPTY", diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index d15de35a960f8..24c8398a060fb 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -108,6 +108,14 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { yield* writeTextFile(cwd, "node_modules/pkg/index.js"); yield* writeTextFile(cwd, "src/index.ts"); yield* fileSystem.makeDirectory(path.join(cwd, "empty")); + yield* git(cwd, ["init", "sub"]); + yield* writeTextFile(cwd, "sub/.gitignore", "dist/\n"); + yield* writeTextFile(cwd, "sub/dist/out.js"); + const gitlink = yield* git(cwd, ["hash-object", "tracked.txt"]); + yield* git(cwd, ["update-index", "--add", "--cacheinfo", `160000,${gitlink},sub`]); + // A nested clone that isn't a submodule keeps the outer repository's ignore rules. + yield* git(cwd, ["init", "node_modules/pkg"]); + yield* git(cwd, ["init", "sub/dist"]); const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const root = yield* workspaceEntries.list({ cwd, directoryPath: "" }); @@ -127,6 +135,17 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { entries: [{ path: "node_modules/pkg/index.js", kind: "file", ignored: true }], truncated: false, }); + // Git refuses pathspecs inside a submodule from the outer repository. + expect((yield* workspaceEntries.list({ cwd, directoryPath: "sub" })).entries).toEqual( + expect.arrayContaining([ + { path: "sub/.gitignore", kind: "file" }, + { path: "sub/dist", kind: "directory", ignored: true }, + ]), + ); + expect(yield* workspaceEntries.list({ cwd, directoryPath: "sub/dist" })).toEqual({ + entries: [{ path: "sub/dist/out.js", kind: "file", ignored: true }], + truncated: false, + }); expect(yield* workspaceEntries.list({ cwd, directoryPath: "empty" })).toEqual({ entries: [], truncated: false, @@ -652,6 +671,29 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + it.effect("keeps matches past the truncated part of a long line", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-long-line-" }); + yield* writeTextFile(cwd, "dist/app.min.js", `${"x".repeat(600)} needleSuffix\n`); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const search = (wholeWord: boolean) => + workspaceEntries.searchContents({ + cwd, + query: "needle", + limit: 100, + caseSensitive: true, + wholeWord, + useRegex: false, + }); + + expect((yield* search(false)).matches).toEqual([ + expect.objectContaining({ path: "dist/app.min.js", lineNumber: 1 }), + ]); + expect((yield* search(true)).matches).toEqual([]); + }), + ); + it.effect("keeps answering from the live index when a refresh outlasts the scan wait", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-refresh-" }); @@ -724,5 +766,30 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); }), ); + + it.effect("marks only a missing directory as missing when browsing fails", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-missing-" }); + yield* writeTextFile(cwd, "file.txt", ""); + + const missing = yield* workspaceEntries + .browse({ partialPath: `${path.join(cwd, "absent", "nested")}${path.sep}` }) + .pipe(Effect.flip); + const underFile = yield* workspaceEntries + .browse({ partialPath: `${path.join(cwd, "file.txt")}${path.sep}` }) + .pipe(Effect.flip); + + expect(missing).toMatchObject({ + _tag: "WorkspaceEntriesReadDirectoryError", + pathMissing: true, + }); + expect(underFile).toMatchObject({ + _tag: "WorkspaceEntriesReadDirectoryError", + pathMissing: false, + }); + }), + ); }); }); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 53f16a830ee3a..3811f92aba5ad 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -62,6 +62,10 @@ export class WorkspaceEntriesReadDirectoryError extends Schema.TaggedError { const root = await NodeFSP.realpath(normalizedCwd); const directory = await NodeFSP.realpath(target.absolutePath); @@ -383,7 +387,17 @@ export const make = Effect.gen(function* () { }, ]; }); + const nestedGitRoots: Array = []; + const segments = target.relativePath.split("/").filter(Boolean); + for (let index = 1; index <= segments.length; index += 1) { + const candidate = segments.slice(0, index).join("/"); + const dotGit = await NodeFSP.lstat(path.join(normalizedCwd, candidate, ".git")).catch( + () => null, + ); + if (dotGit) nestedGitRoots.push(candidate); + } return { + nestedGitRoots, // Coder: capped at `MAX_PROJECT_DIRECTORY_ENTRIES` for the helper stdio frame. entries: listed.slice(0, MAX_PROJECT_DIRECTORY_ENTRIES), truncated: listed.length > MAX_PROJECT_DIRECTORY_ENTRIES, @@ -393,6 +407,8 @@ export const make = Effect.gen(function* () { }, catch: toError, }); + let gitRoot = ""; + let gitPrefix = ""; // stdin avoids argument limits; ignore classification is optional without git/repositories. const checkIgnore = (paths: ReadonlyArray) => vcsProcess @@ -400,20 +416,27 @@ export const make = Effect.gen(function* () { operation: "WorkspaceEntries.list", command: "git", args: ["-c", "core.fsmonitor=false", "check-ignore", "-z", "--stdin"], - cwd: normalizedCwd, - stdin: `${paths.join("\0")}\0`, + cwd: path.join(normalizedCwd, gitRoot), + stdin: `${paths.map((entryPath) => entryPath.slice(gitPrefix.length)).join("\0")}\0`, allowNonZeroExit: true, timeoutMs: 10_000, maxOutputBytes: 16 * 1024 * 1024, }) .pipe(Effect.orElseSucceed(() => undefined)); + // Git refuses paths inside a submodule (128) but still answers for nested clones. + for (const nestedGitRoot of nestedGitRoots) { + if ((yield* checkIgnore([`${nestedGitRoot}/.git`]))?.exitCode !== 128) continue; + gitRoot = nestedGitRoot; + gitPrefix = `${gitRoot}/`; + } + const directoryGitPath = target.relativePath.slice(gitPrefix.length); // Check ignored folders once to avoid child index lookups; exit 0 guarantees no tracked descendants. // Symlinks and pathspec magic still need per-child checks. if ( entries.length > IGNORE_CHECK_CHUNK_SIZE && - target.relativePath !== "" && + directoryGitPath !== "" && !throughSymlink && - !/^:|[*?[\\]/.test(target.relativePath) && + !/^:|[*?[\\]/.test(directoryGitPath) && (yield* checkIgnore([target.relativePath]))?.exitCode === 0 ) { return { @@ -427,7 +450,7 @@ export const make = Effect.gen(function* () { entries.slice(offset, offset + IGNORE_CHECK_CHUNK_SIZE).map((entry) => entry.path), ); if (!result || (result.exitCode !== 0 && result.exitCode !== 1)) break; - for (const ignoredPath of result.stdout.split("\0")) ignored.add(ignoredPath); + for (const ignoredPath of result.stdout.split("\0")) ignored.add(gitPrefix + ignoredPath); } return { entries: entries.map((entry) => diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 786d55dbe0555..3306e420b35c5 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -3,6 +3,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "@effect/platform-node/NodePath"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -66,7 +67,7 @@ const layerTest = Layer.empty.pipe( prefix: "t3-workspace-files-test-", }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const makeTempDir = Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index d5539e2f4e718..8b8782b200504 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -505,7 +505,8 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const matchRanges = mapContentMatchRanges(match.lineContent, match.matchRanges).filter( (range) => !input.wholeWord || isWholeWordRange(match.lineContent, range), ); - if (matchRanges.length === 0) continue; + // fff reports hits past its 512-char lineContent cap with no ranges, so whole words can't be verified there. + if (input.wholeWord && matchRanges.length === 0) continue; matches.push({ path: toPosixPath(match.relativePath), lineNumber: match.lineNumber, diff --git a/apps/server/src/ws.projects.test.ts b/apps/server/src/ws.projects.test.ts index a48ffbc88afcf..7251cd9d6d330 100644 --- a/apps/server/src/ws.projects.test.ts +++ b/apps/server/src/ws.projects.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as RpcTest from "effect/rpc/RpcTest"; import * as Context from "effect/Context"; @@ -22,6 +23,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as ProcessRunner from "./processRunner.ts"; import * as ServerConfig from "./config.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import * as AssetSigner from "./assets/AssetSigner.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as DocumentText from "./provider/DocumentText.ts"; import * as BuiltInDrivers from "./sourceControl/builtInDrivers.ts"; @@ -230,6 +232,7 @@ const harness = ( stub(StorageCleanup.StorageCleanup), stub(CommandOutputHub.CommandOutputHub), stub(ServerSecretStore.ServerSecretStore), + stub(AssetSigner.AssetSigner), stub(ProjectFaviconResolver.ProjectFaviconResolver), stub(DocumentText.DocumentText), stub(ProcessRunner.ProcessRunner), @@ -237,7 +240,7 @@ const harness = ( Layer.succeed(BuiltInDrivers.SourceControlDrivers, []), ServerConfig.layer(config), WorkspacePaths.layer, - ).pipe(Layer.provideMerge(NodeServices.layer)); + ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); const client = yield* RpcTest.makeClient(CoderWsRpcGroup).pipe( Effect.provide(CoderWs.layer.pipe(Layer.provide(dependencies))), ); @@ -264,7 +267,7 @@ describe("Coder RPC seams", () => { "identity:/workspace/project", "status:/workspace/project", ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -299,7 +302,7 @@ describe("Coder RPC seams", () => { threadId: ThreadId.make("thread-too-large"), }).pipe(Effect.flip); assert.equal(error._tag, "OrchestrationThreadTranscriptTooLargeError"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("synthesizes welcome and ready before legacy thread migration progress", () => @@ -331,6 +334,6 @@ describe("Coder RPC seams", () => { [3, "legacyThreadMigration"], ], ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index d36bc397c0e22..8266216f94673 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -6,17 +6,19 @@ import { type OrchestrationV2ThreadShell, type OrchestrationProjectShell, } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Cause from "effect/Cause"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/sql/SqlClient"; +import * as Statement from "effect/sql/Statement"; import { exceedsHelperStdioFrame } from "./coderWs.ts"; -import * as EventStore from "./orchestration-v2/EventStore.ts"; -import * as ProjectionMaintenance from "./orchestration-v2/ProjectionMaintenance.ts"; -import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import * as ThreadManagement from "./orchestration-v2/ThreadManagementService.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts"; @@ -216,6 +218,131 @@ it.effect("coalesces streaming replay before synchronization and the live tail", }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); +it.effect("shell groups read distinct threads once and keep each event's own sequence", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const now = "2026-10-27T00:00:00.000Z"; + const insert = (aggregateKind: "thread" | "project", streamId: string, version: number) => + sql<{ sequence: number }>` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, + occurred_at, actor_kind, payload_json, metadata_json, application_event_version + ) VALUES ( + ${`shell-group:${streamId}:${version}`}, ${aggregateKind}, ${streamId}, ${version}, + ${aggregateKind === "thread" ? "provider-session.detached" : "project.meta-updated"}, + ${now}, 'server', ${JSON.stringify( + aggregateKind === "thread" + ? { providerSessionId: "session:shell-group", detachedAt: now } + : { projectId: streamId, updatedAt: now }, + )}, '{}', 2 + ) RETURNING sequence + `.pipe(Effect.map(([row]) => row!.sequence)); + const shell = (id: string, archivedAt: string | null = null) => + ({ id: ThreadId.make(id), archivedAt }) as unknown as OrchestrationV2ThreadShell; + const project = { + id: ProjectId.make("project:group"), + } as unknown as OrchestrationProjectShell; + + const after = yield* insert("project", "project:before", 0); + yield* insert("thread", "thread:active", 0); + const archived = yield* insert("thread", "thread:archived", 0); + const projectUpdated = yield* insert("project", project.id, 0); + const active = yield* insert("thread", "thread:active", 1); + const missing = yield* insert("thread", "thread:missing", 0); + + const shellReads: Array | undefined> = []; + let batchFails = false; + const collect = (afterSequence: number) => + subscribeOrchestrationV2Shell({ afterSequence, requestCompletionMarker: true }).pipe( + Effect.flatMap((stream) => + stream.pipe( + Stream.takeUntil((item) => item.kind === "synchronized"), + Stream.runCollect, + ), + ), + Effect.provideService(ThreadManagement.ThreadManagementService, { + listActiveSubagentThreadIds: () => Effect.succeed([]), + getShellSnapshot: (options?: { readonly threadIds?: ReadonlyArray }) => + Effect.suspend(() => { + shellReads.push(options?.threadIds); + return batchFails + ? Effect.fail("batch failed") + : Effect.succeed({ + schemaVersion: 1, + snapshotSequence: Number.MAX_SAFE_INTEGER, + threads: [shell("thread:active")], + archivedThreads: [shell("thread:archived", now)], + }); + }), + getThreadShell: (threadId: string) => + Effect.sync(() => { + shellReads.push([`scalar:${threadId}`]); + return threadId === "thread:missing" + ? null + : shell(threadId, threadId === "thread:archived" ? now : null); + }), + } as unknown as ThreadManagement.ThreadManagementService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => Effect.succeed([]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService(ProjectService.ProjectService, { + getShell: () => Effect.succeedSome(project), + } as unknown as ProjectService.ProjectService["Service"]), + Effect.provideServiceEffect( + ProjectEnrichment.ProjectEnrichmentService, + PubSub.unbounded().pipe( + Effect.map( + (changes) => + ({ + subscribeChanges: PubSub.subscribe(changes), + }) as unknown as ProjectEnrichment.ProjectEnrichmentService["Service"], + ), + ), + ), + ); + + const collectSummary = (afterSequence: number) => + collect(afterSequence).pipe( + Effect.map((items) => + items.flatMap((item): ReadonlyArray> => + item.kind === "thread.updated" + ? [[item.kind, item.sequence, item.thread.id]] + : item.kind === "thread.removed" + ? [[item.kind, item.sequence, item.threadId]] + : item.kind === "project.updated" + ? [[item.kind, item.sequence, item.project.id]] + : [], + ), + ), + ); + const expectedGroup = [ + ["thread.removed", archived, "thread:archived"], + ["project.updated", projectUpdated, project.id], + ["thread.updated", active, "thread:active"], + ["thread.removed", missing, "thread:missing"], + ]; + + assert.deepEqual(yield* collectSummary(after), expectedGroup); + assert.deepEqual(shellReads, [["thread:archived", "thread:active", "thread:missing"]]); + + shellReads.length = 0; + assert.deepEqual(yield* collectSummary(active), [ + ["thread.removed", missing, "thread:missing"], + ]); + assert.deepEqual(shellReads, [["scalar:thread:missing"]]); + + shellReads.length = 0; + batchFails = true; + assert.deepEqual(yield* collectSummary(after), expectedGroup); + assert.deepEqual(shellReads, [ + ["thread:archived", "thread:active", "thread:missing"], + ["scalar:thread:archived"], + ["scalar:thread:active"], + ["scalar:thread:missing"], + ]); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), +); + it.effect("shell snapshots omit idle children and older settled threads only on request", () => Effect.gen(function* () { const shell = ( @@ -324,93 +451,176 @@ it.effect("shell snapshots omit idle children and older settled threads only on // helper does not serve; the helper's `subscribeServerConfig` keeps upstream's // subscribe-before-snapshot order and strips workspace snapshots the same way. -// Compaction keeps the oldest and newest event per message, so a cursor older -// than the deleted rows still replays each message where the client would -// first have seen it (reducers append on first sight) and reaches the same -// final state. No snapshot fallback is needed. -it.effect("thread resume across compacted history keeps message order and final state", () => +// Pruned history cannot be replayed: a cursor below the boundary must resync from a snapshot. +it.effect.each(["thread", "shell"] as const)( + "%s resume from a cursor below the pruned boundary loads a snapshot", + (scope) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const now = "2026-10-13T00:00:00.000Z"; + const threadId = ThreadId.make("thread:pruned"); + const insert = (version: number) => sql<{ sequence: number }>` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, + occurred_at, actor_kind, payload_json, metadata_json, application_event_version + ) VALUES ( + ${`pruned:${version}`}, 'thread', ${threadId}, ${version}, 'provider-session.detached', + ${now}, 'server', ${JSON.stringify({ providerSessionId: "session:pruned", detachedAt: now })}, + '{}', 2 + ) RETURNING sequence + `; + const [first] = yield* insert(0); + const [second] = yield* insert(1); + yield* insert(2); + yield* sql` + INSERT INTO orchestration_v2_projection_metadata ( + projection_name, schema_version, last_sequence, updated_at + ) VALUES (${OrchestrationEventStore.EVENT_LOG_PRUNED_THROUGH}, 2, ${second!.sequence}, ${now}) + `; + const subscription = Effect.gen(function* () { + const stream = + scope === "thread" + ? yield* subscribeOrchestrationV2Thread({ threadId, afterSequence: first!.sequence }) + : yield* subscribeOrchestrationV2Shell({ afterSequence: first!.sequence }); + return stream; + }); + const exit = yield* subscription.pipe( + Effect.provideService(ThreadManagement.ThreadManagementService, { + ensureLegacyTranscript: () => Effect.void, + getThreadSnapshot: () => Effect.die("snapshot"), + listActiveShellIndex: () => Effect.die("snapshot"), + readShellSnapshot: () => Effect.die("snapshot"), + listActiveSubagentThreadIds: () => Effect.die("replay"), + } as unknown as ThreadManagement.ThreadManagementService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => Effect.succeed([]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService( + ProjectService.ProjectService, + {} as ProjectService.ProjectService["Service"], + ), + Effect.provideService(ProjectEnrichment.ProjectEnrichmentService, { + subscribeChanges: PubSub.subscribe(yield* PubSub.unbounded()), + } as ProjectEnrichment.ProjectEnrichmentService["Service"]), + Effect.exit, + ); + assert.deepEqual(Exit.isFailure(exit) ? Cause.squash(exit.cause) : exit, "snapshot"); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), +); + +// A prune pass can delete replayed rows after the boundary check; the read must not be trusted. +it.effect.each(["thread", "shell"] as const)( + "%s resume loads a snapshot when pruning advances during the replay read", + (scope) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const now = "2026-10-13T00:00:00.000Z"; + const threadId = ThreadId.make("thread:prune-race"); + const insert = (version: number) => sql<{ sequence: number }>` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, + occurred_at, actor_kind, payload_json, metadata_json, application_event_version + ) VALUES ( + ${`prune-race:${version}`}, 'thread', ${threadId}, ${version}, 'provider-session.detached', + ${now}, 'server', ${JSON.stringify({ providerSessionId: "session:race", detachedAt: now })}, + '{}', 2 + ) RETURNING sequence + `; + const [first] = yield* insert(0); + const [second] = yield* insert(1); + yield* insert(2); + + const readStarted = yield* Deferred.make(); + const releaseRead = yield* Deferred.make(); + let gated = false; + const gateReplayRead: Statement.Transformer = (statement) => { + const [query] = statement.compile(); + // The paged replay read (readApplicationRows), not the stats or high-water queries. + if (!gated && /^\s*SELECT\s+sequence,\s+event_id,/.test(query)) { + gated = true; + return Deferred.succeed(readStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRead)), + Effect.as(statement), + ); + } + return Effect.succeed(statement); + }; + const subscription = Effect.gen(function* () { + const stream = + scope === "thread" + ? yield* subscribeOrchestrationV2Thread({ threadId, afterSequence: first!.sequence }) + : yield* subscribeOrchestrationV2Shell({ afterSequence: first!.sequence }); + return stream; + }).pipe( + Effect.provideService(Statement.CurrentTransformer, gateReplayRead), + Effect.provideService(ThreadManagement.ThreadManagementService, { + ensureLegacyTranscript: () => Effect.void, + getThreadSnapshot: () => Effect.die("snapshot"), + listActiveShellIndex: () => Effect.die("snapshot"), + readShellSnapshot: () => Effect.die("snapshot"), + listActiveSubagentThreadIds: () => Effect.succeed([]), + } as unknown as ThreadManagement.ThreadManagementService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => Effect.succeed([]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService( + ProjectService.ProjectService, + {} as ProjectService.ProjectService["Service"], + ), + Effect.provideService(ProjectEnrichment.ProjectEnrichmentService, { + subscribeChanges: PubSub.subscribe(yield* PubSub.unbounded()), + } as ProjectEnrichment.ProjectEnrichmentService["Service"]), + Effect.exit, + Effect.forkChild, + ); + const fiber = yield* subscription; + yield* Deferred.await(readStarted); + // A pass publishes its boundary, then deletes the rows it covers. + yield* sql` + INSERT INTO orchestration_v2_projection_metadata ( + projection_name, schema_version, last_sequence, updated_at + ) VALUES (${OrchestrationEventStore.EVENT_LOG_PRUNED_THROUGH}, 2, ${second!.sequence}, ${now}) + `; + yield* sql`DELETE FROM orchestration_events WHERE sequence = ${second!.sequence}`; + yield* Deferred.succeed(releaseRead, undefined); + const exit = yield* Fiber.join(fiber); + assert.deepEqual(Exit.isFailure(exit) ? Cause.squash(exit.cause) : exit, "snapshot"); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), +); + +// A thread idle since before the boundary missed nothing; resuming must not resend its body. +it.effect("thread resume at its own high-water below the pruned boundary skips the snapshot", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; const now = "2026-10-13T00:00:00.000Z"; - const threadId = ThreadId.make("thread:compacted"); - const insertMessage = (version: number, id: string, text: string) => sql<{ - sequence: number; - }>` + const threadId = ThreadId.make("thread:idle"); + const [last] = yield* sql<{ sequence: number }>` INSERT INTO orchestration_events ( event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json, application_event_version ) VALUES ( - ${`compacted:${version}`}, 'thread', ${threadId}, ${version}, 'message.updated', - ${now}, 'server', ${JSON.stringify({ - createdBy: "user", - creationSource: "web", - id, - threadId, - runId: null, - nodeId: null, - role: "user", - text, - attachments: [], - streaming: false, - createdAt: now, - updatedAt: now, - })}, '{}', 2 + 'idle:0', 'thread', ${threadId}, 0, 'provider-session.detached', + ${now}, 'server', ${JSON.stringify({ providerSessionId: "session:idle", detachedAt: now })}, + '{}', 2 ) RETURNING sequence `; - const [first] = yield* insertMessage(0, "message:a", "a created"); - const [second] = yield* insertMessage(1, "message:b", "b created"); - yield* insertMessage(2, "message:a", "a superseded"); - const [head] = yield* insertMessage(3, "message:a", "a final"); - // thread.deleted is never superseded: clients switch on the event type. - for (const version of [0, 1]) { - yield* sql` - INSERT INTO orchestration_events ( - event_id, aggregate_kind, stream_id, stream_version, event_type, - occurred_at, actor_kind, payload_json, metadata_json, application_event_version - ) VALUES ( - ${`deleted:${version}`}, 'thread', 'thread:deleted-twice', ${version}, 'thread.deleted', - ${now}, 'server', '{}', '{}', 2 - ) - `; - } - assert.equal((yield* maintenance.compactEventStore()).deletedEventCount, 1); - - const items = yield* subscribeOrchestrationV2Thread({ + yield* sql` + INSERT INTO orchestration_v2_projection_metadata ( + projection_name, schema_version, last_sequence, updated_at + ) VALUES (${OrchestrationEventStore.EVENT_LOG_PRUNED_THROUGH}, 2, ${last!.sequence + 10}, ${now}) + `; + const exit = yield* subscribeOrchestrationV2Thread({ threadId, - afterSequence: first!.sequence - 1, - requestCompletionMarker: true, + afterSequence: last!.sequence, }).pipe( - Effect.flatMap((stream) => - stream.pipe( - Stream.takeUntil((item) => item.kind === "synchronized"), - Stream.runCollect, - ), - ), Effect.provideService(ThreadManagement.ThreadManagementService, { ensureLegacyTranscript: () => Effect.void, - streamStoredEventsFrom: () => Stream.never, + getThreadSnapshot: () => Effect.die("snapshot"), + streamStoredEventsFrom: () => Stream.empty, } as unknown as ThreadManagement.ThreadManagementService["Service"]), + Effect.exit, ); - assert.deepEqual( - Array.from(items, (item) => - item.kind === "event" - ? [item.sequence, item.event.type === "message.updated" ? item.event.payload.text : null] - : [item.kind], - ), - [ - [first!.sequence, "a created"], - [second!.sequence, "b created"], - [head!.sequence, "a final"], - ["synchronized"], - ], - ); - }).pipe( - Effect.provide( - ProjectionMaintenance.layer.pipe( - Layer.provide(Layer.merge(EventStore.layer, ProjectionStore.layer)), - Layer.provideMerge(ReplayTestLayer), - ), - ), - ), + // A snapshot load dies with "snapshot", so success means the resume replayed nothing. + assert.isTrue(Exit.isSuccess(exit)); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 375fded2db8e7..9d384bf74b1a3 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,6 +1,6 @@ import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as DateTime from "effect/DateTime"; @@ -124,9 +124,7 @@ import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore. import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts"; import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; -import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { latestProviderVersionCacheKeys } from "@t3tools/provider-core/server/maintenanceResolver"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerSettings from "./serverSettings.ts"; @@ -243,6 +241,7 @@ function filesystemBrowseFailureContext(error: WorkspaceEntries.WorkspaceEntries readonly failure: FilesystemBrowseFailure; readonly parentPath?: string; readonly platform?: string; + readonly pathMissing?: boolean; } { switch (error._tag) { case "WorkspaceEntriesWindowsPathUnsupportedError": @@ -250,7 +249,11 @@ function filesystemBrowseFailureContext(error: WorkspaceEntries.WorkspaceEntries case "WorkspaceEntriesCurrentProjectRequiredError": return { failure: "current_project_required" }; case "WorkspaceEntriesReadDirectoryError": - return { failure: "read_directory_failed", parentPath: error.parentPath }; + return { + failure: "read_directory_failed", + parentPath: error.parentPath, + ...(error.pathMissing ? { pathMissing: true } : {}), + }; default: return unexpectedCompatibilityError(error); } @@ -342,6 +345,7 @@ const canReplayPersistedRange = Effect.fnUntraced(function* ( if (replayGap < 0 || replayGap > maxGap) { return false; } + if (afterSequence < (yield* applicationEvents.prunedThroughSequence)) return false; const stats = yield* applicationEvents.getReplayStats({ afterSequence, throughSequence: headSequence, @@ -537,7 +541,21 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs }), ), ); - if (input.afterSequence > highWater) { + const prunedThrough = yield* applicationEvents.prunedThroughSequence.pipe( + Effect.mapError( + (cause) => + new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: `Failed to prepare orchestration V2 thread ${input.threadId} replay`, + cause, + }), + ), + ); + // An idle thread's cursor sits at its own high-water: nothing was missed, so replay nothing. + if ( + input.afterSequence > highWater || + (input.afterSequence < highWater && input.afterSequence < prunedThrough) + ) { return yield* snapshotThenLive(); } const stats = yield* applicationEvents @@ -578,9 +596,25 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs // Retained creation events may describe deleted threads; replace replay only if a snapshot exists. if (shell !== null) return yield* snapshotThenLive(); } - const replay = coalesceLiveUpdatedEvents( - yield* loadReplayThrough(input.afterSequence, highWater), - ); + const replayed = yield* loadReplayThrough(input.afterSequence, highWater); + // Pruning publishes its boundary before deleting, so a pass during the read shows up here. + if ( + input.afterSequence < highWater && + input.afterSequence < + (yield* applicationEvents.prunedThroughSequence.pipe( + Effect.mapError( + (cause) => + new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: `Failed to prepare orchestration V2 thread ${input.threadId} replay`, + cause, + }), + ), + )) + ) { + return yield* snapshotThenLive(); + } + const replay = coalesceLiveUpdatedEvents(replayed); const plan = decideThreadResume({ afterSequence: input.afterSequence, highWater, @@ -695,14 +729,37 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc const projectShellItems = Effect.fn("ws.orchestrationV2.projectShellItems")(function* ( events: ReadonlyArray, ) { + const coalesced = coalesceShellApplicationEvents(events); + const threadIds = coalesced.flatMap((stored) => + "aggregateKind" in stored ? [] : [stored.event.threadId], + ); + // A failed batch can't name its thread, so the scalar reads below report which one is bad. + const batch = + threadIds.length > 1 + ? yield* threadManagement + .getShellSnapshot({ threadIds }) + .pipe(Effect.orElseSucceed(() => null)) + : null; + const batchedShells = + batch === null + ? null + : new Map( + [...batch.threads, ...batch.archivedThreads].map( + (shell) => [shell.id, shell] as const, + ), + ); return yield* Effect.forEach( - coalesceShellApplicationEvents(events), + coalesced, (stored) => Effect.gen(function* () { if ("aggregateKind" in stored) { return yield* projectItem(stored); } - const shell = yield* threadManagement.getThreadShell(stored.event.threadId); + const threadId = stored.event.threadId; + const shell = + batchedShells === null + ? yield* threadManagement.getThreadShell(threadId) + : (batchedShells.get(threadId) ?? null); return shellStreamItemFromThreadShell({ stored, shell }); }), { concurrency: 8 }, @@ -798,31 +855,38 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc }); } - const highWater = yield* applicationEvents.latestApplicationSequence; - if (!(yield* canReplayPersistedRange(input.afterSequence, highWater, SHELL_RESUME_MAX_GAP))) { + const fromSnapshot = Effect.gen(function* () { const loaded = yield* loadSnapshot(); return composeShellStreamWithEnrichment({ initial: initialSnapshotItems(loaded), tail: completionThenLive(loaded.snapshot.snapshotSequence), enrichment: enrichmentRefreshes, }); + }); + const highWater = yield* applicationEvents.latestApplicationSequence; + if (!(yield* canReplayPersistedRange(input.afterSequence, highWater, SHELL_RESUME_MAX_GAP))) { + return yield* fromSnapshot; } subagentThreadIds = new Set(yield* threadManagement.listActiveSubagentThreadIds()); const loaded = yield* loadProjectMetadataSnapshot(highWater); - const replay = toShellStream( - applicationEvents - .readApplicationEvents({ - afterSequence: input.afterSequence, - throughSequence: highWater, - // A row a newer build wrote (e.g. after a downgrade) must not fail - // every shell resubscribe. The live tail below resumes from - // highWater, so a dropped row never hides later events. - skipUnknownEventTypes: true, - }) - .pipe(Stream.filter(filterSubagentTranscript)), - ); + // Read before replying: the range is budgeted above, and a prune pass during the read + // publishes its boundary first, so the recheck below catches deleted rows. + const replayed = yield* applicationEvents + .readApplicationEvents({ + afterSequence: input.afterSequence, + throughSequence: highWater, + // A row a newer build wrote (e.g. after a downgrade) must not fail + // every shell resubscribe. The live tail below resumes from + // highWater, so a dropped row never hides later events. + skipUnknownEventTypes: true, + }) + .pipe(Stream.filter(filterSubagentTranscript), Stream.runCollect); + if (input.afterSequence < (yield* applicationEvents.prunedThroughSequence)) { + return yield* fromSnapshot; + } + const replay = toShellStream(Stream.fromIterable(replayed)); return composeShellStreamWithEnrichment({ initial: initialEnrichmentItems(loaded), tail: Stream.concat(Stream.concat(replay, completionMarker), liveFrom(highWater)), @@ -892,13 +956,16 @@ const layerWsRpc = () => const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; const orchestrationEngine = yield* Orchestrator.OrchestratorV2; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; + // Coder: upstream reads the client's settings capabilities from the WebSocket URL; the + // helper's only client is this checkout's web build, which reads secret references. + const settingsClient = { secretReferences: true } as const; // RPC client ids are only unique within one socket; terminals share viewers across sockets. - const terminalViewerPrefix = yield* crypto.randomUUIDv4().pipe(Effect.orDie); + const terminalViewerPrefix = yield* random.uuidV4(); const terminalViewerId = (metadata: { readonly client: { readonly id: number } }) => `${terminalViewerPrefix}:${metadata.client.id}`; const serverCommandId = (tag: string) => - crypto.randomUUIDv4().pipe( + random.uuidV4().pipe( Effect.orDie, Effect.map((id) => CommandId.make(`server:${tag}:${id}`)), ); @@ -932,7 +999,6 @@ const layerWsRpc = () => // Coder: of upstream's ACP Registry services, only the runtime coordinator is used here. const acpRegistryRuntimeCoordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; - const providerLatestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; @@ -1084,7 +1150,7 @@ const layerWsRpc = () => provider?.models.find((candidate) => candidate.isDefault)?.slug ?? provider?.models[0]?.slug ?? "default"; - const commandId = CommandId.make(yield* crypto.randomUUIDv4().pipe(Effect.orDie)); + const commandId = CommandId.make(yield* random.uuidV4()); // Coder: no startup command queue; workspace startup completes before this layer is // built. const launched = yield* Effect.result( @@ -1151,6 +1217,7 @@ const layerWsRpc = () => : currentProviders; const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, + settingsClient, ); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; return { @@ -1349,6 +1416,16 @@ const layerWsRpc = () => getOrchestrationV2ArchivedShellSnapshot, [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: (input) => readOmittableShellRows({ familyOf: input.threadId }, "Failed to load the thread family"), + [ORCHESTRATION_V2_WS_METHODS.getThreadTitles]: (input) => + threadManagement.getThreadTitles(input.threadIds).pipe( + Effect.mapError( + (cause) => + new OrchestrationV2GetShellSnapshotError({ + message: "Failed to load thread titles", + cause, + }), + ), + ), [ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads]: (input) => loadOlderSettledThreadsPage({ sql, @@ -1512,31 +1589,12 @@ const layerWsRpc = () => [WS_METHODS.serverRefreshProviders]: /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* (input) { - // Coder: the model manifest is bundled, so a manual check only drops cached versions. if (input.refreshModels) { - const instances = yield* providerInstances.listInstances; - yield* Effect.forEach( - instances.filter( - (instance) => - input.instanceId === undefined || input.instanceId === instance.instanceId, - ), - (instance) => - Effect.gen(function* () { - yield* instance.invalidateCaches ?? Effect.void; - const maintenance = yield* instance.snapshot.resolveMaintenance({ - fresh: - input.instanceId !== undefined && - input.cwd !== undefined && - input.fresh !== true, - }); - yield* Effect.forEach( - latestProviderVersionCacheKeys(maintenance), - providerLatestVersions.invalidate, - { discard: true }, - ); - }), - { concurrency: "unbounded", discard: true }, - ); + yield* providerRegistry.invalidateModelCaches({ + instanceId: input.instanceId, + cwd: input.cwd, + fresh: input.fresh, + }); } // Coder: host credentials hold no secret references (#27746), so a secret reload has // no host-secret cache to invalidate. @@ -1598,7 +1656,7 @@ const layerWsRpc = () => const settings = yield* providerInstanceMutation === undefined ? serverSettings.updateSettings(patch) : serverSettings.updateProviderInstance(providerInstanceMutation, patch); - return ServerSettings.redactServerSettingsForClient(settings); + return ServerSettings.redactServerSettingsForClient(settings, settingsClient); }), [WS_METHODS.serverDiscoverSourceControl]: (_input) => sourceControlDiscovery.discover, [WS_METHODS.serverListAcpRegistrySessions]: (input) => @@ -2123,7 +2181,9 @@ const layerWsRpc = () => ) : Stream.empty; const settingsUpdates = settingsChanges.pipe( - Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), + Stream.map((settings) => + ServerSettings.redactServerSettingsForClient(settings, settingsClient), + ), Stream.map((settings) => ({ version: 1 as const, type: "settingsUpdated" as const, diff --git a/apps/web/package.json b/apps/web/package.json index a39fa27e95232..5840ecc1551ea 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,13 +1,13 @@ { "name": "@t3tools/web", - "version": "0.1.6", + "version": "0.1.7", "private": true, "type": "module", "scripts": { "dev": "vp dev", "build": "vp build", "preview": "vp preview", - "typecheck": "tsc --noEmit", + "typecheck": "effect-tsgo tsrs --noEmit", "test": "vp test run --passWithNoTests --project unit --project browser" }, "dependencies": { diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts deleted file mode 100644 index bb442959f1813..0000000000000 --- a/apps/web/src/assets/assetUrls.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { EnvironmentId, ThreadId, type AssetResource } from "@t3tools/contracts"; -import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; -import { describe, expect, it } from "vite-plus/test"; - -import { makeRevisionedAssetUrlFamily } from "./assetUrls"; - -const environmentId = EnvironmentId.make("environment-1"); -const resource: AssetResource = { - _tag: "workspace-file", - threadId: ThreadId.make("thread-1"), - path: "docs/diagram.png", -}; - -type MintResult = AsyncResult.AsyncResult<{ relativeUrl: string; expiresAt: number }>; - -const minted = (n: number): MintResult => - AsyncResult.success({ relativeUrl: `/a/${n}`, expiresAt: 0 }); - -// A stand-in for the asset URL query: a refresh starts a mint (counted, shown as -// waiting) and `settle` completes the latest one. -function setup(initial: MintResult) { - let mints = 0; - // Node removal runs on the registry's scheduler; `unmount` flushes it so the - // next mount of the same revision builds a fresh node. - const scheduled: Array<() => void> = []; - const registry = AtomRegistry.make({ - scheduleTask: (task) => { - scheduled.push(task); - return () => {}; - }, - }); - const server = Atom.make(initial).pipe(Atom.keepAlive); - const query = Atom.readable( - (get) => get(server), - () => { - mints += 1; - registry.set(server, AsyncResult.waiting(registry.get(server))); - }, - ).pipe(Atom.keepAlive); - const revisioned = makeRevisionedAssetUrlFamily(() => query); - const mount = (revision: string | null) => { - const release = registry.mount(revisioned({ environmentId, input: { resource }, revision })); - return () => { - release(); - for (const task of scheduled.splice(0)) task(); - }; - }; - const settle = () => registry.set(server, minted(mints + 1)); - return { mount, settle, mints: () => mints }; -} - -describe("makeRevisionedAssetUrlFamily", () => { - it("re-mints a settled URL on first read and on each new revision, not on remount", () => { - const { mount, settle, mints } = setup(minted(0)); - - mount("mutation-1")(); - expect(mints()).toBe(1); - settle(); - - mount("mutation-1")(); - expect(mints()).toBe(1); - - mount("mutation-2")(); - expect(mints()).toBe(2); - }); - - it("re-mints when a revert returns to an earlier revision", () => { - const { mount, settle, mints } = setup(minted(0)); - - mount("mutation-a")(); - settle(); - mount("mutation-b")(); - settle(); - expect(mints()).toBe(2); - - const reverted = mount("mutation-a"); - expect(mints()).toBe(3); - reverted(); - }); - - it("shares a mint in flight on the first read", () => { - const { mount, mints } = setup(AsyncResult.initial(true)); - - mount("mutation-1")(); - expect(mints()).toBe(0); - }); - - it("re-mints after a mint that was in flight when the revision changed", () => { - const { mount, settle, mints } = setup(minted(0)); - - const before = mount("mutation-1"); - settle(); - const edited = mount("mutation-2"); - expect(mints()).toBe(2); - before(); - - const stillMinting = mount("mutation-3"); - edited(); - expect(mints()).toBe(2); - - settle(); - expect(mints()).toBe(3); - settle(); - expect(mints()).toBe(3); - stillMinting(); - }); -}); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 7b48ed923d72e..d14d750894ebe 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -4,12 +4,12 @@ import { assetUrlStateFromResult, blockedAssetUrlState, EMPTY_ASSET_URL_ATOM, + makeRevisionedAssetUrlFamily, resolveAssetUrl, } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { AssetResource, type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; -import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; +import { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; import { assetEnvironment } from "~/state/assets"; import { useFilesystemReadAccess } from "~/state/filesystem"; @@ -20,72 +20,6 @@ export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; export type AssetUrlState = RuntimeAssetUrlState & { readonly waiting?: boolean }; -type AssetUrlTarget = { - readonly environmentId: EnvironmentId; - readonly input: { readonly resource: AssetResource }; -}; - -const decodeRevisionedAssetUrlKey = Schema.decodeUnknownSync( - Schema.Tuple([EnvironmentId, AssetResource, Schema.NullOr(Schema.String)]), -); - -const isSettled = (result: AsyncResult.AsyncResult) => - !AsyncResult.isInitial(result) && !result.waiting; - -/** - * The URL is minted per resource and shared across revisions, so reading a - * different revision (even an earlier one after a revert) re-mints it. An - * in-flight mint is shared only on first read; after an edit the server must - * re-read the file, so the re-mint waits for that mint to settle. - */ -export function makeRevisionedAssetUrlFamily( - createUrl: ( - target: AssetUrlTarget, - ) => Atom.Atom>, -) { - // Outside the nodes: a node is dropped once unmounted, and the next read of - // its revision must still see the revisions minted since. Never pruned on - // purpose: pruning would forget the revision and re-mint on the next mount. - // It holds one short entry per file shown, and goes with the registry. - const mintedUnder = new WeakMap>(); - const family = Atom.family((key: string) => { - const [environmentId, resource, revision] = decodeRevisionedAssetUrlKey(JSON.parse(key)); - const resourceKey = JSON.stringify([environmentId, resource]); - const query = createUrl({ environmentId, input: { resource } }); - return Atom.transform( - query, - (get) => { - let minted = mintedUnder.get(get.registry); - if (minted === undefined) { - minted = new Map(); - mintedUnder.set(get.registry, minted); - } - const firstRead = !minted.has(resourceKey); - let pending = minted.get(resourceKey) !== revision; - const current = get.once(query); - if (pending && (firstRead || isSettled(current))) { - pending = false; - minted.set(resourceKey, revision); - if (isSettled(current)) get.refresh(query); - } - get.subscribe(query, (value) => { - get.setSelf(value); - if (!pending || !isSettled(value)) return; - pending = false; - minted.set(resourceKey, revision); - get.refresh(query); - }); - return get.once(query); - }, - { initialValueTarget: query }, - // No idle TTL: each mount rebuilds the node, so a revision read again after - // another one is compared against the record instead of a cached node. - ).pipe(Atom.setIdleTTL(0)); - }); - return (target: AssetUrlTarget & { readonly revision: string | null }) => - family(JSON.stringify([target.environmentId, target.input.resource, target.revision])); -} - const revisionedAssetUrl = makeRevisionedAssetUrlFamily(assetEnvironment.createUrl); /** diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 08e6f7633dee3..903a20066b39e 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -160,6 +160,18 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { describe("resolveBranchSelectionTarget", () => { it("reuses an existing secondary worktree for the selected ref", () => { + expect( + resolveBranchSelectionTarget({ + activeProjectCwd: "/repo/nested-checkout/packages/app", + activeProjectCheckoutRoot: "/repo/nested-checkout", + activeWorktreePath: null, + refName: { isDefault: true, worktreePath: "/repo" }, + }), + ).toEqual({ + checkoutCwd: "/repo", + nextWorktreePath: "/repo", + reuseExistingWorktree: true, + }); expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", @@ -177,20 +189,31 @@ describe("resolveBranchSelectionTarget", () => { }); it("switches back to the main repo when the ref already lives there", () => { - expect( - resolveBranchSelectionTarget({ - activeProjectCwd: "/repo", - activeWorktreePath: "/repo/.t3/worktrees/feature-a", - refName: { - isDefault: true, - worktreePath: "/repo", - }, - }), - ).toEqual({ - checkoutCwd: "/repo", - nextWorktreePath: null, - reuseExistingWorktree: true, - }); + // A subdirectory project's own checkout is reported at the repository root. + // The checkout root is undefined until the project's status loads. + for (const [activeProjectCwd, activeWorktreePath, activeProjectCheckoutRoot] of [ + ["/repo", "/repo/.t3/worktrees/feature-a", "/repo"], + ["/repo/packages/app", "/repo/.t3/worktrees/feature-a", "/repo"], + ["/repo/packages/app", null, "/repo"], + ["/repo/packages/app", "/repo/.t3/worktrees/feature-a", undefined], + ["/repo/packages/app", null, undefined], + ] as const) { + expect( + resolveBranchSelectionTarget({ + activeProjectCwd, + activeProjectCheckoutRoot, + activeWorktreePath, + refName: { + isDefault: true, + worktreePath: "/repo", + }, + }), + ).toEqual({ + checkoutCwd: "/repo", + nextWorktreePath: null, + reuseExistingWorktree: true, + }); + } }); it("keeps checkout in the current worktree for non-default refs", () => { diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 1ebb161ae87cf..332103466456c 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -7,6 +7,7 @@ import type { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { sanitizeNewRefName } from "@t3tools/shared/git"; +import { workspaceRelativeFilePath } from "@t3tools/shared/path"; import { toSortableTimestamp } from "@t3tools/client-runtime/state/thread-sort"; export { deriveLocalBranchNameFromRemoteRef, sanitizeNewRefName } from "@t3tools/shared/git"; @@ -244,6 +245,7 @@ export function resolveLocalCheckoutBranchMismatch(input: { export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; + activeProjectCheckoutRoot?: string | undefined; activeWorktreePath: string | null; refName: Pick; }): { @@ -254,9 +256,14 @@ export function resolveBranchSelectionTarget(input: { const { activeProjectCwd, activeWorktreePath, refName } = input; if (refName.worktreePath) { + // Until the project's checkout root loads, a checkout containing the project counts as its own. + const isProjectCheckout = + input.activeProjectCheckoutRoot === undefined + ? workspaceRelativeFilePath(activeProjectCwd, refName.worktreePath) !== null + : workspaceRelativeFilePath(input.activeProjectCheckoutRoot, refName.worktreePath) === "."; return { checkoutCwd: refName.worktreePath, - nextWorktreePath: refName.worktreePath === activeProjectCwd ? null : refName.worktreePath, + nextWorktreePath: isProjectCheckout ? null : refName.worktreePath, reuseExistingWorktree: true, }; } diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 2aaf728b43608..fb18047913ace 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -237,6 +237,12 @@ export function BranchToolbarBranchSelector({ input: { cwd: branchCwd }, }), ); + const projectStatusQuery = useEnvironmentQuery( + activeProjectCwd === null + ? null + : vcsEnvironment.status({ environmentId, input: { cwd: activeProjectCwd } }), + ); + const activeProjectCheckoutRoot = projectStatusQuery.data?.workingTree.rootPath; const trimmedBranchQuery = branchQuery.trim(); const branchRefState = usePaginatedBranches({ environmentId, @@ -467,6 +473,7 @@ export function BranchToolbarBranchSelector({ const selectionTarget = resolveBranchSelectionTarget({ activeProjectCwd, + activeProjectCheckoutRoot, activeWorktreePath, refName, }); @@ -709,6 +716,7 @@ export function BranchToolbarBranchSelector({ (!activeProjectCwd || !resolveBranchSelectionTarget({ activeProjectCwd, + activeProjectCheckoutRoot, activeWorktreePath, refName, }).reuseExistingWorktree)) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 98b91f0bd423b..d55786bd9306e 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -10,7 +10,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { SourceControlIconArtwork } from "./SourceControlIconArtwork"; import { Button } from "./ui/button"; -import { setMarkdownTaskChecked } from "./files/filePreviewMode"; +import { setMarkdownTaskChecked } from "@t3tools/shared/markdownTaskList"; vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null })); // Markdown reads only the config fields atom; every other atom reads as empty. diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e0ce3f24a6273..a91afba0a9c38 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -9,6 +9,7 @@ import { extractMarkdownLinkHrefs, inlineCodeFilePathCandidate, } from "@t3tools/shared/markdownLinks"; +import { taskListMarkerOffset } from "@t3tools/shared/markdownTaskList"; import { isHostPath, resolveWorkspaceFilePath } from "@t3tools/shared/path"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; @@ -279,17 +280,6 @@ function CodexArtifactTemplateCard(props: { const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; -function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { - const firstLineEnd = markdown.indexOf("\n", listItemStart); - const firstLine = markdown.slice( - listItemStart, - firstLineEnd === -1 ? markdown.length : firstLineEnd, - ); - const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); - if (!match?.[1]) return null; - return listItemStart + firstLine.indexOf(match[1]); -} - function orderedListGutterStyle( itemCount: number, start: unknown, @@ -861,7 +851,7 @@ const CHAT_MARKDOWN_COMPONENTS = { @@ -873,11 +863,11 @@ const CHAT_MARKDOWN_COMPONENTS = { type="checkbox" name="markdown-task" aria-label="Toggle task" - checked={checked} + checked={checked === true} onChange={(event) => { const listItemStart = Number(event.currentTarget.closest("li")?.dataset.listItemOffset); if (!Number.isSafeInteger(listItemStart)) return; - const markerOffset = findTaskListMarkerOffset(text, listItemStart); + const markerOffset = taskListMarkerOffset(text, listItemStart); if (markerOffset === null) return; onTaskListChange({ markerOffset, checked: event.currentTarget.checked }); }} diff --git a/apps/web/src/components/ChatView.logic.browser.test.ts b/apps/web/src/components/ChatView.logic.browser.test.ts index e1c1f04a0c3a5..233af7115eeb3 100644 --- a/apps/web/src/components/ChatView.logic.browser.test.ts +++ b/apps/web/src/components/ChatView.logic.browser.test.ts @@ -3,20 +3,20 @@ import { userEvent } from "vite-plus/test/browser"; import { shouldRefocusComposerOnWindowFocus } from "./ChatView.logic"; it("keeps keyboard focus on a sidebar row on window return, but not a clicked row", async () => { - const tree = document.createElement("div"); - tree.setAttribute("role", "tree"); - const row = (name: string, tabIndex: number) => { + const list = document.createElement("ul"); + const row = (name: string) => { const element = document.createElement("div"); - element.setAttribute("role", "treeitem"); - element.tabIndex = tabIndex; + element.setAttribute("role", "button"); + element.tabIndex = 0; + element.dataset.sidebarRow = name; element.textContent = name; - tree.append(element); + list.append(element); return element; }; - const clicked = row("Clicked thread", -1); - const tabbed = row("Tabbed thread", 0); - document.body.append(tree); - using _tree = { [Symbol.dispose]: () => tree.remove() }; + const clicked = row("Clicked thread"); + const tabbed = row("Tabbed thread"); + document.body.append(list); + using _list = { [Symbol.dispose]: () => list.remove() }; await userEvent.click(clicked); expect(document.activeElement).toBe(clicked); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 97daec439bb69..c9b81a839a08d 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -6,7 +6,6 @@ import { snapshotComposerSend, withMessageContext, deriveAvailableRevertTurnCounts, - deriveWorkspaceMutationId, } from "./ChatView.logic"; import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; @@ -1174,29 +1173,3 @@ describe("deriveAvailableRevertTurnCounts", () => { expect(deriveAvailableRevertTurnCounts(null).size).toBe(0); }); }); - -describe("deriveWorkspaceMutationId", () => { - const item = (id: string, type: string, status: string) => - ({ - item: { id: TurnItemId.make(id), type, status }, - }) as unknown as OrchestrationV2ProjectedTurnItem; - - it("keys on the latest finished command or file change and the latest checkpoint", () => { - expect( - deriveWorkspaceMutationId( - [ - item("edit", "file_change", "completed"), - item("reply", "assistant_message", "completed"), - item("command", "command_execution", "running"), - ], - "2026-01-01T00:00:00.000Z", - ), - ).toBe(JSON.stringify(["edit", "2026-01-01T00:00:00.000Z"])); - }); - - it("is null before anything could have touched the workspace", () => { - expect(deriveWorkspaceMutationId([item("reply", "assistant_message", "completed")], null)).toBe( - null, - ); - }); -}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 8896abc1ad1a6..cc51cb3853701 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -4,6 +4,7 @@ import { deriveThreadRuntime, } from "@t3tools/client-runtime/state/thread-execution"; import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints"; +import { COMPACT_COMMAND_PATTERN } from "@t3tools/client-runtime/state/context-window"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { latestUnheldRun, @@ -754,10 +755,6 @@ export function shouldShowPlanFollowUpPrompt(input: { ); } -// A typed `/compact`, with or without instructions, is already the compaction. Queueing a -// bare one ahead of it would run an uninstructed compaction first and charge for two. -const COMPACT_COMMAND_PATTERN = /^\/compact(?:\s|$)/i; - const sessionDismissedBranchMismatchKeys = new Set(); export function dismissBranchMismatchForSession(key: string): void { @@ -1086,7 +1083,7 @@ export function shouldRefocusComposerOnWindowFocus( } return ( activeElement.closest( - '[data-workspace-area]:focus-visible, [role="treeitem"]:focus-visible, [role="dialog"], [role="alertdialog"], [role="application"], [role="menu"], [role="listbox"], [data-slot$="-popup"], [data-terminal-owner]', + '[data-workspace-area]:focus-visible, [data-sidebar-row]:focus-visible, [role="dialog"], [role="alertdialog"], [role="application"], [role="menu"], [role="listbox"], [data-slot$="-popup"], [data-terminal-owner]', ) === null ); } @@ -1490,28 +1487,3 @@ export function deriveAvailableRevertTurnCounts(projection: ThreadStructure | nu } return available; } - -/** - * Changes when the agent may have changed the workspace, so file views refresh. File tools are - * explicit; completed commands are included because a shell command can mutate the workspace - * without reporting the paths it touched. - */ -export function deriveWorkspaceMutationId( - visibleTurnItems: ReadonlyArray, - latestCheckpointCompletedAt: string | null, -) { - let itemId: string | null = null; - for (let index = visibleTurnItems.length - 1; index >= 0; index -= 1) { - const item = visibleTurnItems[index]?.item; - if (!item) continue; - if (item.type !== "command_execution" && item.type !== "file_change") continue; - if (item.status === "pending" || item.status === "running" || item.status === "waiting") { - continue; - } - itemId = item.id; - break; - } - return itemId === null && latestCheckpointCompletedAt === null - ? null - : JSON.stringify([itemId, latestCheckpointCompletedAt]); -} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 13e7efbef3888..54bda7f4c9c4a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8,7 +8,6 @@ import { announcedRun, isDetailBehindShell } from "./chat/threadActivityAnnounce import { deriveAvailableRevertTurnCounts, deriveThreadPendingBackgroundTasks, - deriveWorkspaceMutationId, presentThreadShellFromProjection, } from "./ChatView.logic"; import { usePullRequestDraftThread } from "./chat/usePullRequestDraftThread"; @@ -86,6 +85,7 @@ import { import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { HandoffRow } from "./chat/V2LifecycleRow"; import { + deriveWorkspaceMutationId, isCompleteTurnItemRow, shouldShowLoadEarlierControl, } from "@t3tools/client-runtime/state/threads"; @@ -103,6 +103,7 @@ import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { useAtomValue } from "@effect/atom-react"; import { type SetStateAction, use, useEffect, useLayoutEffect, useRef, useState } from "react"; import { BackgroundThreadContext } from "./chat/backgroundThread"; +import { WorkspaceMutationIdContext } from "./chat/ChatMarkdownMedia"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; @@ -150,7 +151,6 @@ import { threadPullRequestPanelTarget } from "./pullRequest/pullRequestDetail.lo import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; import { ComposerContextPermissions } from "./ComposerContextPermissions"; -import { resolveCompatibleRuntimeMode } from "./chat/runtimeModeConfig"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; @@ -263,15 +263,13 @@ import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; -import { - hasAvailableCompactionProvider, - hasDismissedResumeCompaction, - shouldOfferResumeCompaction, -} from "./chat/ContextWindowMeter.logic"; +import { hasAvailableCompactionProvider } from "./chat/ContextWindowMeter.logic"; import { deriveLatestContextWindowSnapshot, + hasDismissedResumeCompaction, latestProviderTurnTokenUsage, -} from "../lib/contextWindow"; + shouldOfferResumeCompaction, +} from "@t3tools/client-runtime/state/context-window"; import { MOBILE_COMPOSER_VIEW_TRANSITION_NAME, MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, @@ -1618,9 +1616,6 @@ export default function ChatView(props: ChatViewProps) { }); }; const scheduleComposerFocusStable = useStableCallback(scheduleComposerFocus); - const openComposerPermissionsStable = useStableCallback(() => - composerRef.current?.openControl("composer.mode"), - ); const insertArtifactTemplate = (template: CodexArtifactTemplate) => { const composer = composerRef.current; if (!composer) return; @@ -2091,6 +2086,8 @@ export default function ChatView(props: ChatViewProps) { openPanelPullRequestUrl, pullRequests: activeThread?.pullRequests, linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, + // Coder: thread links open on the gateway's own origin; there is no hosted app. + hostedAppUrl: window.location.origin, }); const copyActiveThreadReference = () => { if (activeThreadReferenceCopyTarget === null) return; @@ -2286,6 +2283,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadShell?.latestRun ? usageLimitRecoveryBannerItem({ runId: activeThreadShell.latestRun.runId, + // Coder: no ChatGPT plan-limit branch (#25341) or error code; plan limits are cut. resetAt: serverRuntime.usageLimitResetAt ?? null, stoppedAt: activeThreadShell.latestRun.completedAt ?? activeThreadShell.updatedAt, recovery: activeThreadShell.limitRecovery ?? null, @@ -2965,6 +2963,7 @@ export default function ChatView(props: ChatViewProps) { {isWorkspaceFileDragActive ? : null} - + + + {showScrollToBottom && !isThreadFindActive && ( ), })} diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index aa5961e7cbee2..78cde080141ae 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -1,9 +1,12 @@ +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; import { render } from "../../test/render"; import { openCommandPalette } from "../commandPaletteBus"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { makeThreadFixture } from "../test-fixtures"; +import type { Project, Thread } from "../types"; import { CommandPalette } from "./CommandPalette"; // Coder: the palette's Add project reads the Coder config, which the app shell provides. @@ -15,8 +18,20 @@ vi.mock("../coder/CoderBootstrap", () => ({ }), })); -function setup() { +const entities = vi.hoisted(() => ({ + projects: [] as ReadonlyArray, + threads: [] as ReadonlyArray, +})); +vi.mock("../state/entities", async (original) => ({ + ...(await original()), + useProjects: () => entities.projects, + useVisibleThreadShells: () => entities.threads, +})); + +function setup(state: { projects?: ReadonlyArray; threads?: ReadonlyArray } = {}) { useCommandPaletteStore.setState(useCommandPaletteStore.getInitialState(), true); + entities.projects = state.projects ?? []; + entities.threads = state.threads ?? []; } describe("CommandPalette open intents", () => { @@ -39,4 +54,50 @@ describe("CommandPalette open intents", () => { await expect.element(input).toHaveFocus(); await expect.element(input).toHaveValue("acme"); }); + + it("opens a searched project's latest listed thread, not a newer subagent", async () => { + const environmentId = EnvironmentId.make("local"); + const projectId = ProjectId.make("acme"); + const parentId = ThreadId.make("parent"); + setup({ + projects: [ + { + id: projectId, + environmentId, + title: "Acme", + workspaceRoot: "/work/acme", + repositoryIdentity: null, + defaultModelSelection: null, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + scripts: [], + }, + ], + threads: [ + makeThreadFixture({ + id: parentId, + environmentId, + projectId, + latestUserMessageAt: "2026-03-02T00:00:00.000Z", + }), + makeThreadFixture({ + id: ThreadId.make("subagent"), + environmentId, + projectId, + updatedAt: "2026-03-03T00:00:00.000Z", + lineage: { + rootThreadId: parentId, + parentThreadId: parentId, + relationshipToParent: "subagent", + }, + }), + ], + }); + await using screen = await render({null}); + + openCommandPalette({ query: "Acme" }); + await page.getByRole("option", { name: /Acme/ }).first().click(); + + await vi.waitFor(() => expect(screen.router?.state.location.pathname).toBe("/local/parent")); + }); }); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d10ea09fb42ec..2868834331c53 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -10,7 +10,8 @@ import { canCreateProjectInEnvironment, getCloneDestinationBrowsePath, getCloneDestinationPath, - getCloneDirectoryName, + getCloneDirectoryLeafName, + getCloneDirectoryPath, getDefaultCloneUrl, getNewProjectPathPreview, getNewProjectPublishTarget, @@ -177,6 +178,7 @@ import { type SearchOverlayMode, } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; +import { isSidebarHiddenThread } from "./Sidebar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; import { selectCommandPaletteClientSettings, @@ -452,12 +454,15 @@ function OpenCommandPaletteDialog(props: { pullRequests: activeThread?.pullRequests, linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? activeThread?.branchPullRequest?.url ?? null, + // Coder: thread links open on the gateway's own origin; there is no hosted app. + hostedAppUrl: window.location.origin, }); const copyActiveThreadReference = async () => { if (activeThreadReferenceCopyTarget === null) return; await copyThreadReference(activeThreadReferenceCopyTarget); }; - const threads = useVisibleThreadShells(); + // Side chats and subagents open inside their parent, so opening a project never lands on one. + const threads = useVisibleThreadShells().filter((thread) => !isSidebarHiddenThread(thread)); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { changeThemeItem, changeAppearanceItem, themeEditorItem } = useCommandPaletteThemeItems(); const currentView = viewStack.at(-1)?.view ?? null; @@ -595,7 +600,11 @@ function OpenCommandPaletteDialog(props: { const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; const pinnedCloneDirectoryName = addProjectCloneFlow?.step === "confirm" - ? getCloneDirectoryName( + ? getCloneDirectoryLeafName( + addProjectCloneFlow.remoteUrl, + environments.find( + (candidate) => candidate.environmentId === addProjectCloneFlow.environmentId, + )?.serverConfig?.settings.ghqCloneLayout === true, addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.remoteUrl, ) : ""; @@ -1882,8 +1891,20 @@ function OpenCommandPaletteDialog(props: { if (created.value) setOpen(false); } - function getDefaultCloneParentPath(environmentId: EnvironmentId): string { - return getAddProjectInitialQueryForEnvironment(environmentId); + function getDefaultCloneDestination( + environmentId: EnvironmentId, + remoteUrl: string, + repository: string, + ): string { + const environment = environments.find((candidate) => candidate.environmentId === environmentId); + return getCloneDestinationPath( + getAddProjectInitialQueryForEnvironment(environmentId), + getCloneDirectoryPath( + remoteUrl, + environment?.serverConfig?.settings.ghqCloneLayout === true, + repository, + ), + ); } async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise { @@ -1924,9 +1945,11 @@ function OpenCommandPaletteDialog(props: { const provider = addProjectRemoteSourceProvider(addProjectCloneFlow.source); if (!provider) { - const destinationPath = getCloneDestinationPath( - getDefaultCloneParentPath(addProjectCloneFlow.environmentId), - getCloneDirectoryName(rawRepository), + const remoteUrl = normalizePastedCloneUrl(rawRepository); + const destinationPath = getDefaultCloneDestination( + addProjectCloneFlow.environmentId, + remoteUrl, + rawRepository, ); setAddProjectCloneFlow({ step: "confirm", @@ -1934,7 +1957,7 @@ function OpenCommandPaletteDialog(props: { source: addProjectCloneFlow.source, repositoryInput: rawRepository, repository: null, - remoteUrl: normalizePastedCloneUrl(rawRepository), + remoteUrl, }); setHighlightedItemValue(null); setQuery(destinationPath); @@ -1966,9 +1989,11 @@ function OpenCommandPaletteDialog(props: { return; } const repository = lookupResult.value; - const destinationPath = getCloneDestinationPath( - getDefaultCloneParentPath(addProjectCloneFlow.environmentId), - getCloneDirectoryName(repository.nameWithOwner), + const remoteUrl = getDefaultCloneUrl(repository); + const destinationPath = getDefaultCloneDestination( + addProjectCloneFlow.environmentId, + remoteUrl, + repository.nameWithOwner, ); setAddProjectCloneFlow({ step: "confirm", @@ -1976,7 +2001,7 @@ function OpenCommandPaletteDialog(props: { source: addProjectCloneFlow.source, repositoryInput: rawRepository, repository, - remoteUrl: getDefaultCloneUrl(repository), + remoteUrl, }); setHighlightedItemValue(null); setQuery(destinationPath); diff --git a/apps/web/src/components/ComposerContextPermissions.tsx b/apps/web/src/components/ComposerContextPermissions.tsx index b4ac2a2eb6983..94425cf7ebbe3 100644 --- a/apps/web/src/components/ComposerContextPermissions.tsx +++ b/apps/web/src/components/ComposerContextPermissions.tsx @@ -1,41 +1,67 @@ -import type { RuntimeMode } from "@t3tools/contracts"; +import type { EnvironmentId, RuntimeMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { shortcutLabelForCommand } from "../keybindings"; import { primaryServerKeybindingsAtom } from "../state/server"; import { ComposerControl } from "./chat/ComposerControl"; -import { runtimeModeConfig } from "./chat/runtimeModeConfig"; +import { ComposerPermissionsSection } from "./chat/ComposerFooterControls"; +import { useComposerMenuProps } from "./chat/composerEventScope"; +import { + resolveCompatibleRuntimeMode, + resolveRuntimeModeOptions, + runtimeModeConfig, +} from "./chat/runtimeModeConfig"; import { ComposerContextLabel } from "./ComposerContextLabel"; +import { Menu, MenuPopup, MenuTrigger } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -/** The new-thread strip names the permissions the first send will use; clicking opens them. */ export function ComposerContextPermissions(props: { + environmentId: EnvironmentId | null; runtimeMode: RuntimeMode; - onOpen: () => void; + supportedRuntimeModes: ReadonlyArray | undefined; + onRuntimeModeChange: (mode: RuntimeMode) => void; }) { const shortcutLabel = shortcutLabelForCommand( useAtomValue(primaryServerKeybindingsAtom), "composer.mode", ); - const { icon: Icon, label } = runtimeModeConfig[props.runtimeMode]; + const { mode, modes } = resolveCompatibleRuntimeMode( + props.runtimeMode, + props.supportedRuntimeModes, + ); + // Coder: no managed policy (#27197). + const options = resolveRuntimeModeOptions(modes); + const composerMenuProps = useComposerMenuProps(); + const { icon: Icon, label } = runtimeModeConfig[mode]; return ( - - - } - > - - Permissions{shortcutLabel ? ` · ${shortcutLabel}` : null} - + + + + } + /> + } + > + + Permissions{shortcutLabel ? ` · ${shortcutLabel}` : null} + + + + + ); } diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index fc15e3edeb85c..2ddcb01262eaf 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -9,13 +9,15 @@ import { import type { ServerProviderSkill } from "@t3tools/contracts"; import type { Editor } from "@tiptap/core"; +import { redoDepth, undoDepth } from "@tiptap/pm/history"; +import { NodeSelection } from "@tiptap/pm/state"; import { createElement, createRef, useEffect, useState } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; import { act, fakeTimers, render } from "../../test/render"; -import { serializeEditorDoc } from "~/composer-rich-text-doc"; +import { flatToMarkdown, pmToFlat, serializeEditorDoc } from "~/composer-rich-text-doc"; import { isMacPlatform } from "~/lib/utils"; import { ComposerPromptEditorTiptap as ComposerPromptEditor, @@ -149,24 +151,67 @@ it.each([ expect(textWithMark(editor, "code")).toEqual(code); }); -it("bolds with Mod+B only when text is selected", async () => { - await using screen = await mountEditor("ship it"); - const { editor } = screen; - const modB = isMacPlatform(navigator.platform) ? "{Meta>}b{/Meta}" : "{Control>}b{/Control}"; - await act(async () => { - editor.commands.focus("end"); - }); - await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom)); - await userEvent.keyboard(modB); - // Without the guard, Tiptap stores bold for the next typed text. - expect(editor.state.storedMarks).toBeNull(); +it.each([ + { word: "ship", prefix: "", source: ["**ship**"] }, + { word: "path\\", prefix: "", source: [] }, + { word: "2*3", prefix: "", source: [] }, + { word: "b", prefix: "*", source: [] }, + { word: "word", prefix: "\\\\*", source: [] }, + { word: "word", prefix: "\\\\**", source: [] }, + { word: "word", prefix: "\\", source: [] }, + { word: "word", prefix: "\\\\", source: ["**word**"] }, + { word: "word", prefix: "\\\\\\", source: [] }, + { + word: "word", + prefix: "`path\\`", + prefixText: "path\\", + source: ["**word**"], + code: ["path\\"], + }, +])( + "bolds $word after '$prefix' with Mod+B only when text is selected", + async ({ word, prefix, prefixText = prefix, source, code = [] }) => { + const value = `${prefix}${word} it`; + await using screen = await mountEditor(value); + const { editor } = screen; + const modB = isMacPlatform(navigator.platform) ? "{Meta>}b{/Meta}" : "{Control>}b{/Control}"; + await act(async () => { + editor.commands.focus("end"); + }); + await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom)); + await userEvent.keyboard(modB); + // Without the guard, Tiptap stores bold for the next typed text. + expect(editor.state.storedMarks).toBeNull(); - await act(async () => { - editor.commands.setTextSelection({ from: 1, to: 5 }); - }); - await userEvent.keyboard(modB); - await vi.waitFor(() => expect(textWithMark(editor, "bold")).toEqual(["ship"])); -}); + await act(async () => { + editor.commands.setTextSelection({ + from: prefixText.length + 1, + to: prefixText.length + word.length + 1, + }); + }); + await userEvent.keyboard(modB); + await vi.waitFor(() => + expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}**${word}** it`), + ); + expect(textWithMark(editor, "markdownSource")).toEqual(source); + const depth = undoDepth(editor.state); + await userEvent.keyboard("{End}"); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}**${word}** it`); + expect(textWithMark(editor, "bold")).toEqual([word]); + expect(textWithMark(editor, "code")).toEqual(code); + expect(undoDepth(editor.state)).toBe(depth); + await act(async () => { + editor.commands.setTextSelection({ + from: prefixText.length + 1, + to: prefixText.length + word.length + 1, + }); + }); + + // Whether the selection spans shown markers or not, Mod+B removes the bold. + await userEvent.keyboard(modB); + await vi.waitFor(() => expect(serializeEditorDoc(editor.state.doc).value).toBe(value)); + }, +); it.each([false, true])( "composes an umlaut without wrapping the preedit text (rich text: %s)", @@ -199,6 +244,62 @@ it("lets composition replace selected text without surrounding it", async () => expect(serializeEditorDoc(editor.state.doc).value).toBe('"'); }); +it.each([ + { at: "inside bold", selection: 5, composed: "**bo漢ld** after", bold: ["bo漢ld"] }, + { + at: "over the opening markers", + selection: { from: 1, to: 3 }, + composed: "漢bold** after", + bold: [], + }, + { at: "after the closing markers", selection: 9, composed: "**bold**漢 after", bold: ["bold"] }, +])("undoes composition $at in revealed Markdown through the earlier paste", async (row) => { + await using screen = await mountEditor(""); + const { editor } = screen; + const value = () => serializeEditorDoc(editor.state.doc).value; + await act(async () => { + editor.view.focus(); + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", "**bold** after"); + editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true })); + editor.commands.insertContent("!"); + editor.commands.undo(); + editor.commands.setTextSelection(3); + }); + expect(value()).toBe("**bold** after"); + expect(textWithMark(editor, "markdownSource")).toEqual(["**bold**"]); + await act(async () => { + editor.commands.setTextSelection(row.selection); + // A composition canceled before it edits anything keeps the redo and adds no undo. + editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart")); + editor.view.dom.dispatchEvent(new CompositionEvent("compositionend", { data: "" })); + }); + expect([undoDepth(editor.state), redoDepth(editor.state)]).toEqual([1, 1]); + await act(async () => { + editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart")); + // ProseMirror numbers each composition; the canceled one was 1. + editor.view.dispatch(editor.state.tr.insertText("漢").setMeta("composition", 2)); + editor.view.dom.dispatchEvent(new CompositionEvent("compositionend", { data: "漢" })); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + }); + expect(value()).toBe(row.composed); + await act(async () => { + editor.commands.undo(); + }); + expect(value()).toBe("**bold** after"); + await act(async () => { + editor.commands.undo(); + }); + expect(value()).toBe(""); + await act(async () => { + editor.commands.redo(); + editor.commands.redo(); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + }); + expect(value()).toBe(row.composed); + expect(textWithMark(editor, "bold")).toEqual(row.bold); +}); + it("keeps unknown dollar names editable when restoring and pasting a draft", async () => { const value = "echo $HOME $PATH $anything $notaskill $20 $20k $babysit "; await using screen = await mountEditor(value, true, [babysit]); @@ -239,6 +340,256 @@ it("keeps the caret inside unknown dollar text across rich and plain remounts", expect(serializeEditorDoc(editor.state.doc).value).toBe("echo $HOXYME now"); }); +async function focusAt(editor: Editor, position: number | "end") { + // ProseMirror rewrites the DOM caret 20ms after focus. Run that now, or it + // can undo the first key before ProseMirror reads the native move. + using _timers = fakeTimers(); + await act(async () => { + editor.commands.focus(position); + }); + await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom)); + vi.runOnlyPendingTimers(); +} + +describe("Markdown markers around styled text", () => { + it.each([ + { keys: "{Backspace}", expected: "**** after" }, + { keys: "X", expected: "**X** after" }, + ])("keeps a styled chip selected for $keys when showing source", async ({ keys, expected }) => { + await using screen = await mountEditor("$babysit after", true, [babysit]); + const { editor } = screen; + await focusAt(editor, "end"); + await act(async () => { + editor.view.dispatch(editor.state.tr.addMark(1, 2, editor.schema.marks.bold!.create())); + }); + expect(editor.state.doc.nodeAt(1)?.type.name).toBe("composer-skill"); + await act(async () => { + editor.commands.setNodeSelection(1); + }); + expect(editor.state.selection).toBeInstanceOf(NodeSelection); + expect((editor.state.selection as NodeSelection).node.type.name).toBe("composer-skill"); + await userEvent.keyboard(keys); + await userEvent.keyboard("{End}"); + await vi.waitFor(() => expect(serializeEditorDoc(editor.state.doc).value).toBe(expected)); + }); + + it.each([ + { + name: "deleting the opening markers removes bold", + value: "**bold** after", + caret: 1, + keys: "{Delete}{Delete}", + expected: "bold** after", + marks: {}, + }, + { + name: "turning ** into * makes it italic", + value: "**bold** after", + caret: 1, + keys: "{Delete}{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}{Delete}", + expected: "*bold* after", + marks: { italic: ["bold"] }, + }, + { + name: "backspacing from the closing edge keeps italic", + value: "**bold** after", + caret: 1, + keys: async (editor: Editor) => { + await userEvent.keyboard("{Delete}"); + await vi.waitFor(() => expect(editor.state.doc.textContent).toBe("*bold** after")); + for (let position = 2; position <= 8; position += 1) { + await userEvent.keyboard("{ArrowRight}"); + await vi.waitFor(() => expect(editor.state.selection.from).toBe(position)); + } + await userEvent.keyboard("{Backspace}"); + await vi.waitFor(() => + expect(serializeEditorDoc(editor.state.doc).value).toBe("*bold* after"), + ); + }, + expected: "*bold* after", + marks: { italic: ["bold"] }, + }, + { + name: "retyping deleted closing markers restores bold", + value: "**bold** after", + caret: 3, + keys: "{ArrowRight}{ArrowRight}{Delete}{Delete}**", + expected: "**bold** after", + marks: { bold: ["bold"] }, + }, + { + name: "deleting a backtick removes inline code", + value: "`code` after", + caret: 1, + keys: "{Delete}", + expected: "code` after", + marks: {}, + }, + { + name: "a line break inside bold keeps both halves bold", + value: "**bold** after", + caret: 3, + keys: "{Shift>}{Enter}{/Shift}", + expected: "**bo**\n**ld** after", + marks: { bold: ["bo", "ld"] }, + }, + { + name: "splitting a list item inside bold keeps both halves bold", + value: "- **bold** after", + caret: 5, + keys: "{Enter}", + expected: "- **bo**\n- **ld** after", + marks: { bold: ["bo", "ld"] }, + }, + ])("edits the markers as text: $name", async ({ value, caret, keys, expected, marks }) => { + await using screen = await mountEditor(value); + const { editor } = screen; + // Position 1 is the start of the styled word; 3 is two characters into it (5 in a list item). + await focusAt(editor, caret); + if (typeof keys === "string") await userEvent.keyboard(keys); + else await keys(editor); + await userEvent.keyboard("{End}"); + await vi.waitFor(() => expect(serializeEditorDoc(editor.state.doc).value).toBe(expected)); + for (const name of ["bold", "italic", "code"] as const) { + expect(textWithMark(editor, name)).toEqual(marks[name as keyof typeof marks] ?? []); + } + }); + + it("shows nested formatting as Markdown and restores it when the caret leaves", async () => { + const value = "**a *b* c** d"; + await using screen = await mountEditor(value); + const { editor } = screen; + await focusAt(editor, "a b".length + 1); + expect(editor.state.doc.textContent).toBe(value); + expect(textWithMark(editor, "bold")).toEqual([]); + await userEvent.keyboard("{End}"); + await vi.waitFor(() => expect(editor.state.doc.textContent).toBe("a b c d")); + expect(textWithMark(editor, "bold")).toEqual(["a ", "b", " c"]); + expect(textWithMark(editor, "italic")).toEqual(["b"]); + expect(serializeEditorDoc(editor.state.doc).value).toBe(value); + }); + + it("undoes a marker edit in one step without an extra step for showing markers", async () => { + await using screen = await mountEditor("**bold** after"); + const { editor } = screen; + await focusAt(editor, "end"); + await userEvent.keyboard("!"); + await act(async () => { + editor.commands.setTextSelection(1); + }); + await userEvent.keyboard("{Delete}{Delete}{End}"); + const value = () => serializeEditorDoc(editor.state.doc).value; + await vi.waitFor(() => expect(value()).toBe("bold** after!")); + await act(async () => { + editor.commands.undo(); + }); + expect(value()).toBe("**bold** after!"); + expect(textWithMark(editor, "markdownSource")).toEqual(["**bold**"]); + const end = () => editor.state.doc.content.size - 1; + // Undo restored the markers as text; leaving hides them without touching the text undo needs. + await act(async () => { + editor.commands.setTextSelection(end()); + }); + expect(value()).toBe("**bold** after!"); + const hidden = editor.view.dom.querySelectorAll(".composer-rich-marker-hidden"); + expect(Array.from(hidden, (marker) => marker.textContent)).toEqual(["**", "**"]); + await act(async () => { + editor.commands.undo(); + }); + expect(value()).toBe("**bold** after"); + // Moving the caret into styled text keeps redo, and adds no undo step after it. + await act(async () => { + editor.commands.setTextSelection(3); + }); + expect(editor.state.doc.textContent).toBe("**bold** after"); + expect(textWithMark(editor, "markdownSource")).toEqual(["**bold**"]); + await act(async () => { + editor.commands.redo(); + }); + expect(value()).toBe("**bold** after!"); + await act(async () => { + editor.commands.setTextSelection(1); + }); + await act(async () => { + editor.commands.redo(); + }); + expect(value()).toBe("bold** after!"); + expect(textWithMark(editor, "bold")).toEqual([]); + await act(async () => { + editor.commands.undo(); + editor.commands.undo(); + }); + await act(async () => { + editor.commands.redo(); + }); + await act(async () => { + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + }); + await act(async () => { + editor.commands.undo(); + }); + // Undo puts the caret back inside the styled text. + expect(editor.state.doc.textContent).toBe("**bold** after"); + await act(async () => { + editor.commands.setTextSelection(end()); + }); + expect(editor.state.doc.textContent).toBe("bold after"); + expect(textWithMark(editor, "bold")).toEqual(["bold"]); + // After a completed redo, markers still follow the caret, and Delete edits the opening one. + await userEvent.keyboard("?"); + await act(async () => { + editor.commands.undo(); + }); + await act(async () => { + editor.commands.redo(); + }); + expect(editor.state.doc.textContent).toBe("bold after?"); + await act(async () => { + editor.commands.setTextSelection(1); + }); + expect(editor.state.doc.textContent).toBe("**bold** after?"); + expect(textWithMark(editor, "markdownSource")).toEqual(["**bold**"]); + await act(async () => { + editor.commands.setTextSelection(end()); + }); + expect(editor.state.doc.textContent).toBe("bold after?"); + expect(textWithMark(editor, "bold")).toEqual(["bold"]); + await act(async () => { + editor.commands.setTextSelection(1); + }); + await userEvent.keyboard("{Delete}"); + expect(value()).toBe("*bold** after?"); + await act(async () => { + editor.commands.undo(); + }); + expect(value()).toBe("**bold** after?"); + expect(textWithMark(editor, "markdownSource")).toEqual(["**bold**"]); + await act(async () => { + editor.commands.setTextSelection(end()); + }); + expect(editor.state.doc.textContent).toBe("bold after?"); + expect(textWithMark(editor, "bold")).toEqual(["bold"]); + }); + + it("keeps bold around a skill pasted inside it", async () => { + await using screen = await mountEditor("**helloworld** after", true, [babysit]); + const { editor } = screen; + await focusAt(editor, "hello".length + 1); + await act(async () => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", "$babysit"); + editor.view.dom.dispatchEvent( + new ClipboardEvent("paste", { clipboardData, cancelable: true }), + ); + }); + await userEvent.keyboard("{End}"); + await vi.waitFor(() => + expect(serializeEditorDoc(editor.state.doc).value).toBe("**hello** $babysit **world** after"), + ); + expect(textWithMark(editor, "bold")).toEqual(["hello", "world"]); + }); +}); + const file = { type: "file", id: "file-1", @@ -385,26 +736,26 @@ describe("an Arabic line", () => { vi.runOnlyPendingTimers(); } - it("leaves inline code through its start with ArrowRight, which points back", async () => { - // Positions: اكتب at 1-5, sumBy at 6-11, الان at 12-16. - await using screen = await mountEditor("اكتب `sumBy` الان"); + it("walks back through inline code and its backticks with ArrowRight", async () => { + const value = "اكتب `sumBy` الان"; + await using screen = await mountEditor(value); const { editor } = screen; await focusEnd(editor); - const caret = () => ({ - head: editor.state.selection.head, - inCode: (editor.state.storedMarks ?? editor.state.selection.$head.marks()).length > 0, - }); - const visited = [caret()]; - while (visited.at(-1)!.head > 5) { + const offset = () => { + const map = serializeEditorDoc(editor.state.doc); + return flatToMarkdown(map, pmToFlat(map, editor.state.selection.head)); + }; + const visited = [offset()]; + let sawSource = false; + while (visited.at(-1)! > "اكتب".length) { const before = visited.at(-1)!; await userEvent.keyboard("{ArrowRight}"); - await vi.waitFor(() => { - const after = caret(); - expect(after.head < before.head || after.inCode !== before.inCode).toBe(true); - }); - visited.push(caret()); + await vi.waitFor(() => expect(offset()).toBe(before - 1)); + sawSource ||= editor.state.doc.textContent === value; + visited.push(offset()); } - expect(visited.some((stop) => stop.inCode)).toBe(true); + expect(sawSource).toBe(true); + expect(textWithMark(editor, "code")).toEqual(["sumBy"]); }); it("shows the backticks on either side of inline code it is editing", async () => { @@ -414,14 +765,17 @@ describe("an Arabic line", () => { editor.commands.setTextSelection("اكتب su".length + 1); }); const code = editor.view.dom.querySelector("code")!; - const word = document.createRange(); - word.selectNodeContents([...code.childNodes].find((node) => node instanceof Text)!); - const text = word.getBoundingClientRect(); - const [open, close] = [...editor.view.dom.querySelectorAll(".composer-rich-marker")].map( - (marker) => marker.getBoundingClientRect(), - ); - expect(open!.right).toBeLessThanOrEqual(text.left + 1); - expect(close!.left).toBeGreaterThanOrEqual(text.right - 1); + const text = [...code.childNodes].find((node) => node instanceof Text)!; + expect(text.data).toBe("`sumBy`"); + const rect = (from: number, to: number) => { + const range = document.createRange(); + range.setStart(text, from); + range.setEnd(text, to); + return range.getBoundingClientRect(); + }; + const [open, word, close] = [rect(0, 1), rect(1, 6), rect(6, 7)]; + expect(open.right).toBeLessThanOrEqual(word.left + 1); + expect(close.left).toBeGreaterThanOrEqual(word.right - 1); }); it("starts the next line on the right", async () => { diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 3df1e47613370..8c4698210b1d5 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -12,7 +12,6 @@ import { } from "@tiptap/core"; import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; -import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands"; import { isHistoryTransaction } from "@tiptap/pm/history"; import { @@ -59,6 +58,11 @@ import { } from "~/composer-logic"; import { useStableCallback } from "~/hooks/useStableCallback"; import { caretLineDirection, ComposerTextDirectionExtension } from "~/composer-text-direction"; +import { + collapseShownMarkdownSource, + ComposerMarkdownSourceExtension, +} from "~/composer-markdown-source"; +import { MARKDOWN_SOURCE_MARK } from "~/composer-rich-text"; import { collectComposerPromptInlineTokens, selectionTouchesMentionBoundary, @@ -69,7 +73,6 @@ import { ComposerCodeBlockExtension, ComposerListExtensions, buildTiptapContent, - caretTakesMarksBefore, ComposerBoldSelectionOnlyExtension, convertBulletItemToTask, ComposerCodeExtension, @@ -524,46 +527,6 @@ function ComposerContextReferenceNodeView({ node }: NodeViewProps) { ); } -type StyledRange = { - from: number; - to: number; - markers: { at: number; side: number; text: string }[]; -}; - -function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { - const ranges: StyledRange[] = []; - const map = serializeEditorDoc(doc); - let range: StyledRange | null = null; - let openLength = 0; - for (const run of map.runs) { - if (run.nodeName === "codeBlock") continue; - if (run.openLen > 0) { - range ??= { from: run.pmPos, to: run.pmPos, markers: [] }; - range.markers.push({ - at: run.pmPos, - side: -1, - text: map.value.slice(run.mdStart, run.mdStart + run.openLen), - }); - } - if (range === null) continue; - range.to = run.pmPos + run.docLen; - if (run.closeLen > 0) { - const end = run.mdStart + run.mdLen; - range.markers.push({ - at: range.to, - side: -2, - text: map.value.slice(end - run.closeLen, end), - }); - } - openLength += run.openLen - run.closeLen; - if (openLength === 0) { - ranges.push(range); - range = null; - } - } - return ranges; -} - /** Wait for a character after '- ' so task markers reach their input rule before becoming bullets. */ function listMarkerInputRule(find: RegExp, listType: "bulletList" | "orderedList"): InputRule { return new InputRule({ @@ -684,75 +647,41 @@ function isInCodeBlock(view: EditorView): boolean { return view.state.selection.$from.parent.type.spec.code === true; } -const MarkerPluginKey = new PluginKey("composer-rich-markers"); +const ChipSelectionPluginKey = new PluginKey("composer-chip-range-selection"); -const ComposerMarkerPlugin = new Plugin({ - key: MarkerPluginKey, - state: { - init: (_, state) => decorationsForSelection(state), - apply: (tr, old, _, state) => - tr.docChanged || tr.selectionSet || tr.storedMarksSet ? decorationsForSelection(state) : old, - }, - props: { - decorations(state) { - return MarkerPluginKey.getState(state); - }, +/** Highlights chips inside a text selection; their node views draw no native highlight. */ +const ComposerChipSelectionExtension = Extension.create({ + name: "composer-chip-range-selection", + addProseMirrorPlugins() { + return [ + new Plugin({ + key: ChipSelectionPluginKey, + state: { + init: (_, state) => chipSelectionDecorations(state), + apply: (tr, old, _, state) => + tr.docChanged || tr.selectionSet ? chipSelectionDecorations(state) : old, + }, + props: { + decorations: (state) => ChipSelectionPluginKey.getState(state), + }, + }), + ]; }, }); -function decorationsForSelection(state: EditorState): DecorationSet { - const { doc, selection } = state; - const caretSide = caretTakesMarksBefore(state) ? 3 : 0; - const rtl = caretLineDirection(state) === "rtl"; +function chipSelectionDecorations({ doc, selection }: EditorState): DecorationSet { + if (selection.empty) return DecorationSet.empty; const decorations: Decoration[] = []; - if (!selection.empty) { - doc.nodesBetween(selection.from, selection.to, (node, pos) => { - if (node.type.name.startsWith("composer-")) { - decorations.push( - Decoration.node(pos, pos + node.nodeSize, { class: "composer-chip-range-selected" }), - ); - return false; - } - return true; - }); - } - for (const range of collectStyledRanges(doc)) { - const active = selection.empty - ? selection.from >= range.from && selection.from <= range.to - : selection.from < range.to && selection.to > range.from; - if (!active) continue; - for (const { at, side: baseSide, text } of range.markers) { - const side = selection.empty && at === selection.from ? baseSide + caretSide : baseSide; - const marker = document.createElement("span"); - marker.className = "composer-rich-marker"; - marker.textContent = text; - marker.setAttribute("aria-hidden", "true"); - // Inline code is isolated left to right. In a right-to-left line its - // opening backtick joins the closing one inside the isolate, or the line - // would put both on the same side of the code. - const codeMarks = - rtl && baseSide === -1 && /^`+$/.test(text) - ? doc.resolve(at).nodeAfter?.marks.filter((mark) => mark.type.spec.code) - : undefined; - decorations.push( - Decoration.widget(at, marker, { - side, - key: `marker-${at}-${side}-${marker.textContent}`, - ...(codeMarks?.length ? { marks: codeMarks } : {}), - }), - ); - } - } + doc.nodesBetween(selection.from, selection.to, (node, pos) => { + if (!node.type.name.startsWith("composer-")) return true; + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { class: "composer-chip-range-selected" }), + ); + return false; + }); return DecorationSet.create(doc, decorations); } -const ComposerMarkersExtension = Extension.create({ - name: "composer-rich-markers", - addProseMirrorPlugins() { - return [ComposerMarkerPlugin]; - }, -}); - type TiptapEditor = NonNullable>; function replaceSkillChips( @@ -842,9 +771,10 @@ function createComposerExtensions(richText: boolean) { ComposerSkillExtension, ComposerCitationExtension, ComposerContextReferenceExtension, - ComposerMarkersExtension, + ComposerChipSelectionExtension, ...(richText ? [ + ComposerMarkdownSourceExtension, ComposerBoldSelectionOnlyExtension, ComposerItalicExtension, ComposerBoldExtension, @@ -1291,7 +1221,11 @@ function ComposerPromptEditorTiptapInner( return true; } event.preventDefault(); - if ((isTaskItem || isListItem) && instance && splitOrLiftListItem(instance)) { + if ( + (isTaskItem || isListItem) && + instance && + splitOrLiftListItem(instance, collapseShownMarkdownSource) + ) { return true; } if ( @@ -1303,6 +1237,15 @@ function ComposerPromptEditorTiptapInner( ) { return true; } + // Parse shown Markdown first, or the split leaves each half with one delimiter. + if (instance) { + return instance + .chain() + .command(collapseShownMarkdownSource) + .command(({ state, dispatch }) => splitBlockKeepMarks(state, dispatch)) + .scrollIntoView() + .run(); + } return splitBlockKeepMarks(view.state, (tr) => { view.dispatch(tr.scrollIntoView()); }); @@ -1330,11 +1273,12 @@ function ComposerPromptEditorTiptapInner( return false; } // Do not wrap chips, atoms, or styled text; default replacement preserves marks. + // Shown Markdown source is plain text, so typed delimiters wrap it. let touchesSpecial = false; view.state.doc.nodesBetween(from, to, (node) => { if ( (node.isAtom && node.isInline && !node.isText) || - (node.isText && node.marks.length > 0) + (node.isText && node.marks.some((mark) => mark.type.name !== MARKDOWN_SOURCE_MARK)) ) { touchesSpecial = true; return false; @@ -1368,6 +1312,22 @@ function ComposerPromptEditorTiptapInner( ); return true; } + // Shown Markdown source is parsed when the caret leaves, so paste one line into it as text. + const sourceType = view.state.schema.marks[MARKDOWN_SOURCE_MARK]; + const { from: pasteFrom, to: pasteTo, $from: $pasteFrom } = view.state.selection; + if ( + sourceType?.isInSet($pasteFrom.marks()) && + !pastedText.includes("\n") && + collectComposerPromptInlineTokens(`${pastedText}\n`).length === 0 + ) { + view.dispatch( + markAsClipboardEdit( + view.state.tr.insertText(pastedText, pasteFrom, pasteTo), + "paste", + ).scrollIntoView(), + ); + return true; + } const importFragment = readCallbacks().importContextFragment; let text = !literalText && importFragment @@ -1408,6 +1368,8 @@ function ComposerPromptEditorTiptapInner( markAsClipboardEdit(tr, "paste"); return true; }) + // Pasted chips and lines would split shown Markdown, so parse it first. + .command(collapseShownMarkdownSource) .insertContent(content) .command(({ tr }) => { // Move after a pasted rule so the next keystroke cannot replace it; add a trailing line @@ -1562,9 +1524,8 @@ function ComposerPromptEditorTiptapInner( }, ); } + selectMarkdownOffset(editor, normalizedExpandedCursor); const map = serializeEditorDoc(editor.state.doc); - const flat = markdownToFlat(map, normalizedExpandedCursor); - editor.commands.setTextSelection(flatToPm(map, flat)); if (isFocused) scrollTiptapCaretIntoView(editor); if (pendingCitation) { citationRequestRef.current = null; @@ -1633,8 +1594,7 @@ function ComposerPromptEditorTiptapInner( const boundedCursor = clampEditorCursor(snapshotRef.current.value, nextCursor); const expandedCursor = exactExpandedCursor ?? expandEditorCursor(snapshotRef.current.value, boundedCursor); - const map = serializeEditorDoc(editor.state.doc); - editor.commands.setTextSelection(flatToPm(map, markdownToFlat(map, expandedCursor))); + selectMarkdownOffset(editor, expandedCursor); scrollTiptapCaretIntoView(editor); if (boundedCursor === snapshotRef.current.cursor) return; snapshotRef.current = { @@ -1852,6 +1812,17 @@ function insertMarkdownParagraphs( insertContent(blocks as JSONContent[]); } +/** Placing the caret can show Markdown markers, so place it again in the shown source. */ +function selectMarkdownOffset(editor: TiptapEditor, offset: number): void { + for (let pass = 0; pass < 2; pass += 1) { + const map = serializeEditorDoc(editor.state.doc); + const pos = flatToPm(map, markdownToFlat(map, offset)); + const { selection } = editor.state; + if (pass > 0 && selection.empty && selection.from === pos) return; + editor.commands.setTextSelection(pos); + } +} + /** Native scrolling follows only real input; explicitly reveal programmatically placed carets. */ function scrollTiptapCaretIntoView(editor: TiptapEditor): void { editor.view.dispatch(editor.state.tr.scrollIntoView()); diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index 505c11130d692..3157c3e5da2a7 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -8,6 +8,7 @@ import { type ClientSettings, type ReviewBranchCommitsResult, type ReviewDiffPreviewResult, + type ReviewDiffPreviewSource, type ScopedThreadRef, type VcsListRefsResult, type VcsStatusResult, @@ -18,7 +19,7 @@ import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; -import { render } from "../../test/render"; +import { act, render } from "../../test/render"; import { makeThreadFixture } from "~/test-fixtures"; type ResultAtom
= Atom.Atom>; @@ -27,6 +28,8 @@ const state = vi.hoisted(() => ({ thread: null as ReturnType | null, threadRef: null as ScopedThreadRef | null, previews: new Map>(), + patch: null as unknown as ResultAtom, + diffFilesCollapsed: false, config: null as unknown as Atom.Atom, status: null as unknown as ResultAtom, localRefs: null as unknown as ResultAtom, @@ -42,6 +45,7 @@ vi.mock("~/state/review", () => ({ diffPreview: ({ input }: { input: { baseRef?: string } }) => state.previews.get(input.baseRef ?? null)!, branchCommits: () => state.commits, + diffFilePatch: () => state.patch, diffFileContents: {}, }, })); @@ -64,10 +68,14 @@ vi.mock("~/state/filesystem", () => ({ vi.mock("~/hooks/useThreadRouteTarget", () => ({ useThreadRouteRef: () => state.threadRef })); vi.mock("~/hooks/useSettings", () => ({ useClientSettings: (select: (settings: ClientSettings) => unknown) => - select(DEFAULT_CLIENT_SETTINGS), + select({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: state.diffFilesCollapsed }), updateClientSettings: async () => {}, })); -vi.mock("~/hooks/useTheme", () => ({ useRenderedAppearance: () => "dark" })); +vi.mock("~/hooks/useTheme", () => ({ + useRenderedAppearance: () => "dark", + getRenderedAppearance: () => "dark", + subscribeRenderedAppearance: () => () => {}, +})); vi.mock("~/state/queries", () => ({ useCheckpointDiff: () => ({ data: null, error: null, isPending: false }), useTurnItemDetail: () => ({ data: null, error: null, isPending: false }), @@ -76,8 +84,6 @@ vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => async () => { vi.mock("~/editorPreferences", () => ({ useOpenInPreferredEditor: () => async () => {} })); vi.mock("~/fileContextMenu", () => ({ useFileContextMenuHandler: () => () => {} })); vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => async () => {} })); -// The test drives comparison controls, without mounting virtualized diff viewers or their workers. -vi.mock("./diffs/AnnotatableCodeView", () => ({ AnnotatableCodeView: () => null })); vi.mock("./diffs/DiffFileTree", () => ({ DiffFileTree: () => null })); vi.mock("./DiffFilePathCopyButton", () => ({ DiffFilePathCopyButton: () => null })); @@ -101,6 +107,8 @@ function preview(): ReviewDiffPreviewResult { diff: "", diffHash: "empty", truncated: false, + patchesDeferred: true, + files: [], }, ], }; @@ -116,6 +124,11 @@ function setup(failedBaseRef: string | null) { useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} }); }; reset(); + state.diffFilesCollapsed = false; + const patch = Atom.make>( + AsyncResult.initial(), + ); + state.patch = patch; state.threadRef = { environmentId: EnvironmentId.make("environment"), threadId: ThreadId.make("thread"), @@ -167,6 +180,7 @@ function setup(failedBaseRef: string | null) { const registry = AtomRegistry.make(); return { registry, + patch, threadRef: state.threadRef, [Symbol.dispose]() { registry.dispose(); @@ -212,3 +226,43 @@ it.each([ }); }, ); + +it.each([false, true])( + "expands an unloaded file on the first click with default collapse %s", + async (collapsed) => { + using context = setup("origin/orphan"); + state.diffFilesCollapsed = collapsed; + const path = `pending-${collapsed}.txt`; + const metadata = preview(); + const source = { + ...metadata.sources[0]!, + files: [{ path, previousPath: null, additions: 1, deletions: 1 }], + }; + state.previews.set("main", Atom.make(AsyncResult.success({ ...metadata, sources: [source] }))); + useDiffPanelStore.getState().selectBranchBaseRef(context.threadRef, "main"); + await using screen = await render( + +
+ +
+
, + ); + const toggle = screen.getByRole("button", { name: `Expand ${path}`, exact: true }); + await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(toggle); + act(() => + context.registry.set( + context.patch, + AsyncResult.success({ + ...source, + diff: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-before\n+after\n`, + }), + ), + ); + const loadedToggle = screen.getByRole("button", { name: `Collapse ${path}`, exact: true }); + await expect.element(loadedToggle).toHaveAttribute("aria-expanded", "true"); + await expect.element(screen.getByText("after", { exact: true })).toBeVisible(); + await userEvent.click(loadedToggle); + await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); + }, +); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index f702ccd8ef220..9fd56fd5431a8 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -98,6 +98,7 @@ import { vcsEnvironment } from "../state/vcs"; import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; import { createChunkedGitDiffFileContentsLoader } from "../lib/diffFileContents"; +import { observeIntersection } from "~/lib/observeIntersection"; import { useReviewFilePatches } from "./diffs/useReviewFilePatches"; import { DiffFileLoadingBoundary } from "./diffs/DiffFileLoadingBoundary"; import { DiffFileStatus } from "./diffs/DiffFileStatus"; @@ -137,13 +138,13 @@ function DiffFileCollapseToggle({ filePath, fileKey, collapsed, - unavailable, onToggle, + load, }: { filePath: string; fileKey: string; collapsed: boolean; - unavailable: boolean; + load?: () => void; onToggle: (fileKey: string) => void; }) { return ( @@ -156,7 +157,16 @@ function DiffFileCollapseToggle({ className="-ms-0.5" aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`} aria-expanded={!collapsed} - disabled={unavailable} + ref={(node) => { + if (!node || !load) return; + return observeIntersection( + node, + (entry) => { + if (entry.isIntersecting) load(); + }, + { rootMargin: "600px" }, + ); + }} onClick={(event) => { event.stopPropagation(); onToggle(fileKey); @@ -367,9 +377,15 @@ export default function DiffPanel({ ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), ...(selectedCommitSha !== null ? { commitSha: selectedCommitSha } : {}), ignoreWhitespace: diffIgnoreWhitespace, - // Coder: ask the helper for only the selected source. - sourceKind: - selectedGitScope === "unstaged" ? ("working-tree" as const) : ("branch-range" as const), + metadataOnly: true, + ...(selectedCommitSha === null + ? { + sourceKind: + selectedGitScope === "unstaged" + ? ("working-tree" as const) + : ("branch-range" as const), + } + : {}), } : undefined; const canRefreshGitDiff = @@ -473,16 +489,25 @@ export default function DiffPanel({ : selectedTurn ? activeCheckpointDiff : branchDiffPreview; - const isLoadingSelectedPatch = selectedPatchQuery.isPending && selectedPatchQuery.data === null; + const isLoadingSelectedPatch = + selectedPatchQuery.isPending && + (selectedPatchQuery.data === null || + (isGitSelection && preview?.incomplete === true && selectedGitSource?.files?.length === 0)); const selectedPatchError = toolCallSelection ? toolCallDetail.error : selectedTurn ? activeCheckpointDiff.error : branchDiffPreview.error; const hasResolvedPatch = typeof selectedPatch === "string"; - const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; + const hasNoNetChanges = + hasResolvedPatch && + selectedPatch.trim().length === 0 && + !preview?.incomplete && + (!selectedGitSource?.patchesDeferred || selectedGitSource.files?.length === 0); const lazySource = - isGitSelection && selectedGitSource?.truncated && selectedGitSource.files + isGitSelection && + (selectedGitSource?.truncated || selectedGitSource?.patchesDeferred) && + selectedGitSource.files?.length ? selectedGitSource : null; const renderablePatch = getRenderablePatch( @@ -606,14 +631,12 @@ export default function DiffPanel({ ...matchingBaseRefChoices.map(valueForBaseRefChoice), ]; const { - scope: filePatchScope, isPending: areFilePatchesPending, fileStates, retry, requestFile, - readyFilePaths, renderableFiles, - settledFileCount, + remainingFileCount, loadNextFiles, } = useReviewFilePatches({ environmentId: activeThread?.environmentId, @@ -649,7 +672,16 @@ export default function DiffPanel({ ); const isRefreshingDiff = branchDiffPreview.isPending || areFilePatchesPending; - const renderableFileEntries = renderableFiles.map(getCachedFileEntry); + const pathOccurrences = new Map(); + const renderableFileEntries = renderableFiles.map((fileDiff) => { + const entry = getCachedFileEntry(fileDiff); + if (!isGitSelection) return entry; + // Metadata has no change type; keep each path's identity when its patch arrives. + const pathKey = JSON.stringify([fileDiff.prevName ?? fileDiff.name, fileDiff.name]); + const occurrence = pathOccurrences.get(pathKey) ?? 0; + pathOccurrences.set(pathKey, occurrence + 1); + return { ...entry, fileKey: `${pathKey}:${occurrence}` }; + }); const diffFileKeys = renderableFileEntries.map((file) => file.fileKey); const testFileCount = renderableFiles.filter((file) => isTestChangedFile(resolveFileDiffPath(file)), @@ -677,25 +709,17 @@ export default function DiffPanel({ .updateCollapsedFileKeys(collapseScopeKey, defaultCollapsedDiffFileKeys, update); }; const renderLoadingBoundary = () => - settledFileCount < renderableFiles.length ? ( - + remainingFileCount > 0 ? ( + ) : null; - const codeViewFiles = shownFileEntries - .filter( - ({ fileDiff }) => - !lazySource || !readyFilePaths || readyFilePaths.has(resolveFileDiffPath(fileDiff)), - ) - .map(({ fileDiff, fileKey, fileVersion }) => ({ - fileDiff, - filePath: resolveFileDiffPath(fileDiff), - fileKey, - fileVersion, - collapsed: - collapsedDiffFileKeys.has(fileKey) || fileDiff.cacheKey?.endsWith(":pending") === true, - })); + const codeViewFiles = shownFileEntries.map(({ fileDiff, fileKey, fileVersion }) => ({ + fileDiff, + filePath: resolveFileDiffPath(fileDiff), + fileKey, + fileVersion, + collapsed: + collapsedDiffFileKeys.has(fileKey) || fileDiff.cacheKey?.endsWith(":pending") === true, + })); const pendingFiles = renderableFiles.filter( (file) => file.cacheKey?.endsWith(":pending") && isFileShown(file), ); @@ -714,9 +738,7 @@ export default function DiffPanel({ next.delete(file.fileKey); return next; }); - if (lazySource && index >= settledFileCount) { - requestFile(index); - } + if (lazySource) requestFile(filePath); codeViewReveal.request(file.fileKey); return true; }; @@ -748,9 +770,12 @@ export default function DiffPanel({ }); }; const toggleDiffFileCollapsed = useStableCallback((fileKey: string) => { + const file = renderableFileEntries.find((file) => file.fileKey === fileKey); + if (lazySource && file) requestFile(resolveFileDiffPath(file.fileDiff)); + const pending = file?.fileDiff.cacheKey?.endsWith(":pending") === true; updateCollapsedDiffFiles((current) => { const next = new Set(current); - if (next.has(fileKey)) { + if (pending || next.has(fileKey)) { next.delete(fileKey); } else { next.add(fileKey); @@ -1298,10 +1323,19 @@ export default function DiffPanel({ ) : ( <>
+ {preview?.incomplete && ( +

+ {selectedPatchError + ? "Showing tracked changes. Untracked changes could not be loaded. Refresh to retry." + : "Showing tracked changes. Checking untracked files..."} +

+ )} {isSelectedPatchTruncated && !lazySource && (

This preview exceeds the size limit. Changes shown are incomplete. - {isGitSelection && selectedGitSource?.files ? " Totals include all changes." : ""} + {isGitSelection && selectedGitSource?.files && !preview?.incomplete + ? " Totals include all changes." + : ""}

)} {omittedToolCallFiles > 0 && ( @@ -1416,7 +1450,7 @@ export default function DiffPanel({ { - const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true; + renderHeaderPrefix={(fileDiff, fileKey, collapsed) => { return ( requestFile(resolveFileDiffPath(fileDiff)), + } + : {})} /> ); }} diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 7d87f480c4385..a2cc2124f4684 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -251,7 +251,7 @@ describe("resolveAutoFeatureBranchName", () => { describe("resolveChangesTotals", () => { it("does not let an empty branchChanges hide a dirty working tree", () => { - assert.deepEqual( + assert.deepInclude( resolveChangesTotals( status({ hasWorkingTreeChanges: true, @@ -271,7 +271,7 @@ describe("resolveChangesTotals", () => { assert.deepEqual( resolveChangesTotals( status({ - branchChanges: { baseRef: "main", insertions: 20, deletions: 3 }, + branchChanges: { baseRef: "origin/master", insertions: 20, deletions: 3 }, workingTree: { files: [{ path: "a.ts", insertions: 1, deletions: 0 }], insertions: 1, @@ -279,12 +279,12 @@ describe("resolveChangesTotals", () => { }, }), ), - { insertions: 20, deletions: 3 }, + { insertions: 20, deletions: 3, baseRef: "origin/master" }, ); }); it("falls back to the working tree when branchChanges is omitted", () => { - assert.deepEqual( + assert.deepInclude( resolveChangesTotals( status({ workingTree: { diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index ca396893accda..5effb44c79a87 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -429,6 +429,8 @@ export function resolveLiveThreadBranchUpdate(input: { export type ChangesLineTotals = { readonly insertions: number; readonly deletions: number; + /** Ref the counts compare against; `null` means they are uncommitted changes against HEAD. */ + readonly baseRef: string | null; }; // `branchChanges ?? workingTree` treats `{0,0}` as present, so a zero branch total hid a dirty @@ -438,15 +440,16 @@ export function resolveChangesTotals( ): ChangesLineTotals | null { if (!status) return null; const branch = status.branchChanges; - const tree = status.workingTree; - if (branch && (branch.insertions > 0 || branch.deletions > 0)) { - return { insertions: branch.insertions, deletions: branch.deletions }; - } - if (tree.insertions > 0 || tree.deletions > 0) { - return { insertions: tree.insertions, deletions: tree.deletions }; - } - if (branch) { - return { insertions: branch.insertions, deletions: branch.deletions }; - } - return { insertions: tree.insertions, deletions: tree.deletions }; + const tree = { ...status.workingTree, baseRef: null }; + const selected = + branch && (branch.insertions > 0 || branch.deletions > 0) + ? branch + : tree.insertions > 0 || tree.deletions > 0 + ? tree + : (branch ?? tree); + return { + insertions: selected.insertions, + deletions: selected.deletions, + baseRef: selected.baseRef, + }; } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 0069e82556b39..edbcff874363f 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1045,10 +1045,13 @@ export default function GitActionsControl({ const isRepo = gitStatus?.isRepo ?? true; const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; const gitStatusForActions = gitStatus; - const changesTotals = resolveChangesTotals(gitStatusForActions) ?? { - insertions: 0, - deletions: 0, - }; + const resolvedChangesTotals = resolveChangesTotals(gitStatusForActions); + const changesTotals = resolvedChangesTotals ?? { insertions: 0, deletions: 0 }; + const changesBaseLabel = resolvedChangesTotals + ? resolvedChangesTotals.baseRef === null + ? "Uncommitted" + : `vs ${resolvedChangesTotals.baseRef}` + : null; const allFiles = gitStatusForActions?.workingTree.files ?? []; // Coder: status paths are repository-relative, and the Files surface opens paths relative to @@ -1616,8 +1619,22 @@ export default function GitActionsControl({ onClick={onOpenChanges} > - Changes - + Changes + {changesBaseLabel ? ( + + + } + > + {changesBaseLabel} + + + {changesBaseLabel} + + + ) : null} + + {props.settings ? ( + + {props.settings} + + ) : null} + {props.pin ?? } + + ); +} diff --git a/apps/web/src/components/ProjectEnvironmentBadge.tsx b/apps/web/src/components/ProjectEnvironmentBadge.tsx index e9257d0de9408..e1ece5dfd53b4 100644 --- a/apps/web/src/components/ProjectEnvironmentBadge.tsx +++ b/apps/web/src/components/ProjectEnvironmentBadge.tsx @@ -28,7 +28,7 @@ export function ProjectEnvironmentBadge(props: { } > diff --git a/apps/web/src/components/Sidebar.browser.test.tsx b/apps/web/src/components/Sidebar.browser.test.tsx new file mode 100644 index 0000000000000..875a2225d70a1 --- /dev/null +++ b/apps/web/src/components/Sidebar.browser.test.tsx @@ -0,0 +1,89 @@ +import { DEFAULT_CLIENT_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { openCommandPalette } from "../commandPaletteBus"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { __setClientSettingsForTests, getClientSettings } from "../hooks/useSettings"; +import { useProjects } from "../state/entities"; +import type { Project } from "../types"; +import Sidebar from "./Sidebar"; +import { SidebarProvider } from "./ui/sidebar"; + +vi.mock("../state/entities", { spy: true }); +vi.mock("../hooks/useHandleNewThread", { spy: true }); +vi.mock("../commandPaletteBus", () => ({ + openCommandPalette: vi.fn(), + isCommandPaletteOpen: () => false, +})); +vi.mock("./sidebar/SidebarChrome", () => ({ + SidebarChromeHeader: () => null, + SidebarChromeFooter: () => null, +})); + +function project(id: string): Project { + return { + id: ProjectId.make(id), + environmentId: EnvironmentId.make("local"), + title: id, + workspaceRoot: `/projects/${id}`, + repositoryIdentity: null, + defaultModelSelection: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [], + }; +} + +describe("sidebar header new thread", () => { + it.each([ + { mode: "normal", count: 2, shift: false, picker: true }, + { mode: "boring", count: 2, shift: false, picker: true }, + { mode: "boring", count: 2, shift: true, picker: false }, + { mode: "boring", count: 1, shift: false, picker: false }, + { mode: "legacy", count: 2, shift: false, picker: false }, + ] as const)("$mode sidebar, $count projects, shift=$shift: picker=$picker", async (scenario) => { + using stack = new DisposableStack(); + const previousSettings = getClientSettings(); + stack.defer(() => __setClientSettingsForTests(previousSettings)); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + boringSidebarEnabled: scenario.mode === "boring", + legacySidebarEnabled: scenario.mode === "legacy", + }); + const projects = [project("active"), project("idle")].slice(0, scenario.count); + const currentProjectRef = { + environmentId: projects[0]!.environmentId, + projectId: projects[0]!.id, + }; + const startThread = vi.fn().mockResolvedValue(null); + vi.mocked(useProjects).mockReturnValue(projects); + vi.mocked(useHandleNewThread).mockReturnValue({ + activeDraftThread: null, + activeThread: null, + defaultProjectRef: currentProjectRef, + handleNewThread: startThread, + routeDraftId: null, + routeThreadRef: null, + }); + vi.mocked(openCommandPalette).mockClear(); + await using screen = await render( + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: "New thread", exact: true }), { + modifiers: scenario.shift ? ["Shift"] : [], + }); + + if (scenario.picker) { + expect(openCommandPalette).toHaveBeenCalledExactlyOnceWith({ open: "new-thread-in" }); + expect(startThread).not.toHaveBeenCalled(); + } else { + expect(openCommandPalette).not.toHaveBeenCalled(); + expect(startThread).toHaveBeenCalledExactlyOnceWith(currentProjectRef); + } + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 669c805492bcc..9e7489a0ffca1 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -11,8 +11,6 @@ import { getFallbackThreadIdAfterDelete, hasUnseenCompletion, resolveSidebarRowThreadId, - belongsInSidebarWorkingShelf, - isSidebarEnvironmentDisconnected, orderItemsByPreferredIds, pinOrderKeyBetween, resolveSidebarSweepKeys, @@ -286,7 +284,13 @@ describe("resolveSidebarThreadStatus", () => { ).toBe(false); }); - it("reports failed only while the latest run failed", () => { + it("reports worktree setup as preparing, and failed only while the latest run failed", () => { + expect( + resolveSidebarThreadStatus({ + ...idle, + runtime: { ...runtime, status: "preparing" as const }, + }), + ).toBe("preparing"); expect( resolveSidebarThreadStatus({ ...idle, @@ -624,10 +628,45 @@ describe("getFallbackThreadIdAfterDelete", () => { deletedThreadId: ThreadId.make("thread-active"), deletedThreadIds: new Set([ThreadId.make("thread-active"), ThreadId.make("thread-newest")]), sortOrder: "created_at", + now: "2026-03-09T10:10:00.000Z", }); expect(fallbackThreadId).toBe(ThreadId.make("thread-next")); }); + + it.each([ + [ + "snoozed", + { snoozedUntil: "2099-01-01T00:00:00.000Z", snoozedAt: "2026-03-09T10:06:00.000Z" }, + ], + ["settled", { settledOverride: "settled", settledAt: "2026-03-09T10:06:00.000Z" }], + ] as const)("skips a %s thread parked on its shelf", (_state, parked) => { + const fallbackThreadId = getFallbackThreadIdAfterDelete({ + threads: [ + makeThread({ + id: ThreadId.make("thread-visible"), + createdAt: "2026-03-09T10:01:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-parked"), + createdAt: "2026-03-09T10:02:00.000Z", + updatedAt: "2026-03-09T10:06:00.000Z", + ...parked, + }), + makeThread({ + id: ThreadId.make("thread-active"), + createdAt: "2026-03-09T10:03:00.000Z", + updatedAt: "2026-03-09T10:03:00.000Z", + }), + ], + deletedThreadId: ThreadId.make("thread-active"), + sortOrder: "created_at", + now: "2026-03-09T10:10:00.000Z", + }); + + expect(fallbackThreadId).toBe(ThreadId.make("thread-visible")); + }); }); describe("sortScopedProjectsForSidebar", () => { @@ -969,38 +1008,6 @@ describe("Working shelf (beta)", () => { ).toBe(false); }); - it.each([ - ["connected", true, false], - ["connected", false, true], - ["connecting", false, false], - ["reconnecting", false, true], - ["offline", false, true], - ["error", false, true], - ["unsupported", false, true], - ["available", false, true], - ] as const)( - "lists threads outside the shelf on a %s environment: %s", - (phase, shellIsLive, disconnected) => { - expect(isSidebarEnvironmentDisconnected(phase, shellIsLive)).toBe(disconnected); - }, - ); - - it("lists a running thread outside the shelf while its environment is disconnected", () => { - const thread = { ...idle, runtime, environmentId: localEnvironmentId }; - const shelve = (disconnected: ReadonlyArray, enabled = true) => - belongsInSidebarWorkingShelf(thread, { - enabled, - disconnectedEnvironmentIds: new Set(disconnected), - isWorking: isSidebarThreadWorking, - }); - - expect(shelve([])).toBe(true); - expect(shelve([localEnvironmentId])).toBe(false); - expect(shelve([])).toBe(true); - expect(shelve([EnvironmentId.make("environment-other")])).toBe(true); - expect(shelve([], false)).toBe(false); - }); - describe("sortInboxThreadsByReturn", () => { const thread = ( id: string, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 653d7b9df0964..35338bc7c7cf8 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,7 +16,6 @@ import { type ThreadSearchShell, } from "@t3tools/client-runtime/state/thread-search"; import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; -import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { isListedThreadLineage, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; import type { ContextMenuItem } from "../localApiTypes"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; @@ -181,29 +180,6 @@ export function resolveSidebarThreadSection(input: { return "active"; } -// Keep cached placement on the first connection attempt, matching mobile's threadRouteIsHydrating. -export function isSidebarEnvironmentDisconnected( - phase: EnvironmentConnectionPhase, - shellIsLive: boolean, -): boolean { - return phase !== "connecting" && (phase !== "connected" || !shellIsLive); -} - -export function belongsInSidebarWorkingShelf( - thread: T, - input: { - readonly enabled: boolean; - readonly disconnectedEnvironmentIds: ReadonlySet; - readonly isWorking: (thread: T) => boolean; - }, -): boolean { - return ( - input.enabled && - !input.disconnectedEnvironmentIds.has(thread.environmentId) && - input.isWorking(thread) - ); -} - const SIDEBAR_MARKER_PREFIX = "sidebar-marker-"; export type SidebarListMarker = @@ -836,6 +812,7 @@ export type SidebarThreadStatus = | "approval" | "input" | "request" + | "preparing" | "working" | "waiting" | "failed" @@ -857,7 +834,9 @@ export function shouldRecedeSidebarThread(input: { ) { return false; } - if (input.status === "working" || input.status === "waiting") return true; + if (input.status === "preparing" || input.status === "working" || input.status === "waiting") { + return true; + } if (input.status === "ready" || input.status === "approval") { return !input.isUnread && !input.isWoke; } @@ -888,9 +867,12 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si if (thread.runtime?.status === "running" && thread.blockedOnThreadWait === true) { return "waiting"; } + if (thread.runtime?.status === "preparing") { + return "preparing"; + } if ( thread.runtime !== null && - ["preparing", "queued", "starting", "running", "waiting"].includes(thread.runtime.status) + ["queued", "starting", "running", "waiting"].includes(thread.runtime.status) ) { return "working"; } @@ -925,7 +907,7 @@ export function resolveSidebarFolderDot( (thread) => resolveSidebarThreadStatus(thread) === "ready" && hasUnseenCompletion(thread), ); if (hasUnread) return "unread"; - if (statuses.has("working")) return "working"; + if (statuses.has("working") || statuses.has("preparing")) return "working"; return null; } @@ -1198,14 +1180,17 @@ export function resolveThreadStatusPill(input: { } export function getFallbackThreadIdAfterDelete< - T extends Pick & ThreadSortInput, + T extends Pick & + ThreadSortInput & + ThreadSnoozeShell, >(input: { threads: readonly T[]; deletedThreadId: T["id"]; sortOrder: SidebarThreadSortOrder; + now: string; deletedThreadIds?: ReadonlySet; }): T["id"] | null { - const { deletedThreadId, deletedThreadIds, sortOrder, threads } = input; + const { deletedThreadId, deletedThreadIds, sortOrder, now, threads } = input; const deletedThread = threads.find((thread) => thread.id === deletedThreadId); if (!deletedThread) { return null; @@ -1217,7 +1202,9 @@ export function getFallbackThreadIdAfterDelete< (thread) => thread.projectId === deletedThread.projectId && thread.id !== deletedThreadId && - !deletedThreadIds?.has(thread.id), + !deletedThreadIds?.has(thread.id) && + thread.settledOverride !== "settled" && + !effectiveSnoozed(thread, { now }), ), sortOrder, )[0]?.id ?? null diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 862c660af4ecf..965d3e7953246 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,5 +1,4 @@ -import { liveShellEnvironmentIdsAtom } from "../state/shell"; -import { attachSidebarTree, SIDEBAR_TREE_PROPS } from "../lib/sidebarTree"; +import { attachSidebarTree } from "../lib/sidebarTree"; import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost"; @@ -139,7 +138,6 @@ import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironmentIdentities, useConnectedEnvironmentIds, - useEnvironmentConnectionSummaries, useEnvironmentMachines, usePrimaryEnvironmentId, } from "../state/environments"; @@ -168,6 +166,7 @@ import type { SidebarThreadSummary } from "../types"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { cn } from "~/lib/utils"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; +import { ProjectRowActions } from "./ProjectComboboxRow"; import { buildDraftActionMenuItems, buildThreadActionMenuItems, @@ -183,8 +182,6 @@ import { deleteSelectedThreadEntries, filterSidebarProjectScopeItems, firstValidTimestampMs, - belongsInSidebarWorkingShelf, - isSidebarEnvironmentDisconnected, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -352,7 +349,6 @@ interface SidebarThreadPartitionInput { readonly workingShelfEnabled: boolean; /** Boring mode: folders list active threads by the user's latest message. */ readonly sortActiveBySend: boolean; - readonly disconnectedEnvironmentIds: ReadonlySet; // Snooze wakes, the queued-turn grace and the Working shelf's exit grace depend // on the clock, so the minute tick, the precise snooze-wake tick and the inbox // tracker's expiry version are inputs: a new key repartitions. @@ -438,18 +434,6 @@ function useSidebarThreads(input: { // Folders already separate the work, so boring mode has no Working shelf. const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled) && folderGroups === null; - const environmentConnections = useEnvironmentConnectionSummaries(); - const liveShellEnvironmentIds = useAtomValue(liveShellEnvironmentIdsAtom); - const disconnectedEnvironmentIds = new Set( - environmentConnections - .filter((environment) => - isSidebarEnvironmentDisconnected( - environment.connectionState, - liveShellEnvironmentIds.has(environment.environmentId), - ), - ) - .map((environment) => environment.environmentId), - ); const inboxReturnsVersion = useSyncExternalStore( inboxReturns.subscribe, inboxReturns.getSnapshot, @@ -494,7 +478,6 @@ function useSidebarThreads(input: { optimisticDrop, workingShelfEnabled, sortActiveBySend: folderGroups !== null, - disconnectedEnvironmentIds, clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, }); @@ -630,7 +613,6 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { optimisticDrop, workingShelfEnabled, sortActiveBySend, - disconnectedEnvironmentIds, } = input; const preciseNow = new Date().toISOString(); const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); @@ -639,11 +621,8 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const active: EnvironmentThreadShell[] = []; const working: EnvironmentThreadShell[] = []; const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) => - belongsInSidebarWorkingShelf(thread, { - enabled: workingShelfEnabled, - disconnectedEnvironmentIds, - isWorking: manuallyMoved ? isThreadWorking : inboxObservation.isWorking, - }) + workingShelfEnabled && + (manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) ? working : active; const snoozed: EnvironmentThreadShell[] = []; @@ -1003,6 +982,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, }); const boringSidebarEnabled = useBoringSidebarEnabled(); + const legacySidebarEnabled = useClientSettings((settings) => settings.legacySidebarEnabled); const folderRowsMultiline = useClientSettings((settings) => settings.boringSidebarMultilineRows); const newThreadContext = useHandleNewThread(); // Row callbacks capture only the handler; the rest of the context changes with the route. @@ -1028,7 +1008,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const projectScopeFilter = useComboboxFilter(); const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady(); const allProjectConfigsReady = useAtomValue(environmentProjects.projectConfigsReadyAtom); - const headerSearchRef = useRef(null); // Safari may click after Ctrl+click opens settings; suppress that selection until the picker reopens. const suppressNextScopeChangeRef = useRef(false); const highlightedProjectScopeKeyRef = useRef(null); @@ -2801,7 +2780,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } return; case "copy-thread": - void copyThreadReference(threadChipCopyTarget(threadRef, thread.title)); + void copyThreadReference( + // Coder: thread links open on the gateway's own origin; there is no hosted app. + threadChipCopyTarget(threadRef, thread.title, window.location.origin), + ); return; case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); @@ -2942,7 +2924,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const handleNewThreadClick = (event?: ReactMouseEvent) => { if ( - boringSidebarEnabled || + legacySidebarEnabled || shouldCreateNewThreadInCurrentProject( event?.shiftKey ?? false, scopeGroups.length, @@ -2981,7 +2963,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve fixedHeader={ 0} projectScope={ {scopedProjectGroups.length > 1 ? ( - - {scopedProjectGroups.slice(0, 2).map((project) => ( - + {scopedProjectGroups.slice(0, 2).map((project, index) => ( + + className={ + index === 0 + ? "flex" + : "-ml-1 flex rounded-[25%] bg-sidebar ring-2 ring-sidebar" + } + > + + ))} ) : scopedProjectGroups[0] ? ( @@ -3052,8 +3038,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve {/* Refocusing the trigger after a pointer close shows its focus ring and opens its tooltip. */} closeType === "keyboard"} > )} - {item.label} + {item.label} {project && showProjectEnvironments ? ( ) : null} - {project && canPinProject(project) ? ( - toggleProjectGroupPin(project)} - /> - ) : null} {project ? ( - - ) : null} + toggleProjectGroupPin(project)} + /> + ) : null + } + settings={ + + } + /> + ) : ( + // Keeps "All projects" as tall as the project rows. + + )} ); }} @@ -3174,7 +3171,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve newThreadShortcutLabel={newThreadShortcutLabel} newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel} showNewThreadInProjectHint={ - !boringSidebarEnabled && + !legacySidebarEnabled && !shouldCreateNewThreadInCurrentProject( false, scopeGroups.length, @@ -3284,8 +3281,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve >
    0 && "flex-1", @@ -3573,8 +3572,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
  • )} - +
    {item.type === "video" ? ( diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx index 68ab4cd569e0d..e0e6ae6d19e0d 100644 --- a/apps/web/src/components/chat/MarkdownThreadLink.tsx +++ b/apps/web/src/components/chat/MarkdownThreadLink.tsx @@ -1,7 +1,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId } from "@t3tools/contracts"; import { - formatThreadLink, + formatHostedThreadLink, guessThreadLinkRef, percentDecodedThreadLinkId, type ThreadLink, @@ -36,6 +36,10 @@ export function MarkdownThreadLink(props: { const ref = { environmentId: thread.environmentId, threadId: thread.id }; const title = thread.title.trim() || props.label || "Untitled thread"; return ( - + ); } diff --git a/apps/web/src/components/chat/McpAppFrame.tsx b/apps/web/src/components/chat/McpAppFrame.tsx index d65758f08d45d..fc697333d6108 100644 --- a/apps/web/src/components/chat/McpAppFrame.tsx +++ b/apps/web/src/components/chat/McpAppFrame.tsx @@ -40,9 +40,10 @@ const commandFailure = (result: { readonly cause: Parameters[0]["cause"]; }) => { const error = squashAtomCommandFailure(result); - return new McpAppHostRefusal( - error instanceof Error && error.message.trim() !== "" ? error.message : "Request failed.", - ); + return new McpAppHostRefusal({ + message: + error instanceof Error && error.message.trim() !== "" ? error.message : "Request failed.", + }); }; const fullscreenSupported = @@ -253,7 +254,7 @@ export function McpAppFrame(props: { flushSync(() => setDisplayMode("inline")); const approved = await requestConfirmDialog(message); if (approved === undefined) { - throw new McpAppHostRefusal("T3 could not ask for approval here."); + throw new McpAppHostRefusal({ message: "T3 could not ask for approval here." }); } return approved; }; @@ -272,12 +273,13 @@ export function McpAppFrame(props: { const { environmentId, input } = scope(); const info = await readLive().toolInfo({ environmentId, input: { ...input, name } }); if (info._tag !== "Success") throw commandFailure(info); - if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool."); + if (!info.value.callable) + throw new McpAppHostRefusal({ message: "This app cannot call that tool." }); if (!info.value.readOnly) { const approved = await ask( `Allow ${app.server} to run ${info.value.title ?? name}?\n${JSON.stringify(args, null, 2)}`, ); - if (approved !== true) throw new McpAppHostRefusal("Declined by the user."); + if (approved !== true) throw new McpAppHostRefusal({ message: "Declined by the user." }); } const result = await readLive().callTool({ environmentId, @@ -297,15 +299,16 @@ export function McpAppFrame(props: { }, openLink: async (url) => { if (document.activeElement !== frameRef.current || !navigator.userActivation?.isActive) { - throw new McpAppHostRefusal("Links open only from a click in the app."); + throw new McpAppHostRefusal({ message: "Links open only from a click in the app." }); } window.open(url, "_blank", "noopener,noreferrer"); }, sendMessage: async (text) => { const send = readLive().props.onSendMessage; - if (send === undefined) throw new McpAppHostRefusal("Messages are not available here."); + if (send === undefined) + throw new McpAppHostRefusal({ message: "Messages are not available here." }); const approved = await ask(`Send this message from ${app.server}?\n${text}`); - if (approved !== true) throw new McpAppHostRefusal("Declined by the user."); + if (approved !== true) throw new McpAppHostRefusal({ message: "Declined by the user." }); await send(text); }, updateModelContext: async (context) => { @@ -340,7 +343,7 @@ export function McpAppFrame(props: { downloadFile: async (files) => { const names = files.map((file) => file.name).join(", "); const approved = await ask(`Save ${names} from ${app.server}?`); - if (approved !== true) throw new McpAppHostRefusal("Declined by the user."); + if (approved !== true) throw new McpAppHostRefusal({ message: "Declined by the user." }); for (const file of files) { let bytes: Uint8Array | undefined; let mimeType = file.mimeType ?? "application/octet-stream"; @@ -358,9 +361,10 @@ export function McpAppFrame(props: { const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType; if (typeof declared === "string") mimeType = declared; } - if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`); + if (bytes === undefined) + throw new McpAppHostRefusal({ message: `${file.name} has no contents.` }); if (bytes.byteLength > MAX_DOWNLOAD_BYTES) { - throw new McpAppHostRefusal(`${file.name} is too large to save.`); + throw new McpAppHostRefusal({ message: `${file.name} is too large to save.` }); } saveBlob(new Blob([bytes.slice()], { type: mimeType }), file.name); } diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 9b156deaed8a2..775d32da13d27 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -1,6 +1,6 @@ import { flushSync } from "react-dom"; import { AudioPreview } from "../files/AudioPreview"; -import { page } from "vite-plus/test/browser"; +import { cdp, page } from "vite-plus/test/browser"; import { ComposerContextId, EnvironmentId, @@ -236,7 +236,9 @@ describe("MessagesTimeline work rows", () => { { container }, ); const heading = screen.getByRole("button", { name: /^Worked for/ }); - await expect.element(heading).toBeVisible(); + // LegendList keeps rows click-through until its initial scroll settles on animation + // frames, which the fake clock below would freeze. + await expect.element(heading).toHaveStyle({ pointerEvents: "auto" }); const viewport = props.listRef.current!.getScrollableNode()!; await vi.waitFor(() => expect(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop).toBeLessThan(2), @@ -565,6 +567,76 @@ describe("MessagesTimeline messages", () => { }); }); +function usageLimitEntries(resetAt: string) { + const threadId = ThreadId.make("usage-limit-thread"); + const at = DateTime.makeUnsafe(CREATED_AT); + return deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + { + position: 0, + visibility: "local", + sourceThreadId: threadId, + sourceItemId: TurnItemId.make("usage-limit"), + item: { + id: TurnItemId.make("usage-limit"), + threadId, + runId: RunId.make("usage-limit-run"), + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + type: "error", + status: "failed", + failure: { + class: "usage_limit", + message: "You've hit your usage limit.", + code: null, + retryable: true, + resetAt, + }, + }, + }, + ], + optimisticMessages: [], + }); +} + +describe("MessagesTimeline usage limit notice", () => { + it("labels the retry time with the viewer's timezone", async () => { + await using screen = await renderTimeline(usageLimitEntries("2026-03-17T19:30:00.000Z")); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + expect(screen.text()).toMatch(/Usage limit reached\. Retry after .+\./); + expect(screen.text()).toContain(`(${timeZone})`); + }); + + it("keeps the retry time in the named zone after the viewer's timezone changes", async () => { + const resetAt = "2026-07-01T19:30:00.000Z"; + const session = cdp(); + await session.send("Emulation.setTimezoneOverride", { timezoneId: "Europe/Madrid" }); + try { + { + await using _madrid = await renderTimeline(usageLimitEntries(resetAt)); + } + await session.send("Emulation.setTimezoneOverride", { timezoneId: "America/New_York" }); + await using screen = await renderTimeline(usageLimitEntries(resetAt)); + const newYorkTime = new Date(resetAt).toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + timeZone: "America/New_York", + }); + expect(screen.text()).toContain(`${newYorkTime} (America/New_York)`); + } finally { + await session.send("Emulation.setTimezoneOverride", { timezoneId: "" }); + } + }); +}); + const CITATION_REQUEST = { key: "activation-1", citation: { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e32a7eb485fc6..4c7eba620b5d1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1528,60 +1528,6 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); - it("keeps the latest finished reply's timestamp while a command is still running", () => { - const reply = (streaming: boolean) => ({ - id: "assistant-reply-entry", - kind: "message" as const, - createdAt: "2026-01-01T00:00:10Z", - message: { - id: "assistant-reply" as never, - role: "assistant" as const, - text: "Training finished, checking memory next.", - runId: "turn-1" as never, - createdAt: "2026-01-01T00:00:10Z", - updatedAt: "2026-01-01T00:00:12Z", - streaming, - }, - }); - const rowsWith = (streaming: boolean) => - deriveMessagesTimelineRows({ - timelineEntries: [ - reply(streaming), - { - id: "command-entry", - kind: "work", - createdAt: "2026-01-01T00:00:15Z", - entry: { - id: "command", - createdAt: "2026-01-01T00:00:15Z", - runId: "turn-1" as never, - label: "Running command", - command: "nvidia-smi", - tone: "tool" as const, - toolLifecycleStatus: "inProgress" as const, - }, - }, - ], - latestRun: { - runId: "turn-1" as never, - status: "running", - startedAt: "2026-01-01T00:00:00Z", - completedAt: null, - }, - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaries: [], - supportsConversationRollback: false, - }).find((row) => row.id === "assistant-reply-entry"); - - expect(rowsWith(false)).toMatchObject({ - showAssistantMeta: false, - showAssistantTimestamp: true, - showAssistantCopyButton: false, - }); - expect(rowsWith(true)).toMatchObject({ showAssistantTimestamp: false }); - }); - it("does not show Thinking once the open run's provider turn has ended", () => { const input = { timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 8a4a7552901a2..2fe811db4d18f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -591,8 +591,6 @@ type MessagesTimelineRowContent = message: ChatMessage; projectedItem?: OrchestrationV2ProjectedTurnItem; showAssistantMeta: boolean; - /** The latest finished reply of a still-running response shows only its timestamp. */ - showAssistantTimestamp: boolean; showAssistantCopyButton: boolean; assistantCopyStreaming: boolean; assistantTurnDiffSummary?: TurnDiffSummary | undefined; @@ -2029,10 +2027,10 @@ export function deriveMessagesTimelineRows(input: { timelineEntry.message.role === "assistant" && runIdIsActiveResponse(timelineEntry.message.runId); - const isTerminalAssistantMessage = + const showAssistantMeta = timelineEntry.message.role === "assistant" && - terminalAssistantMessageIds.has(timelineEntry.message.id); - const showAssistantMeta = isTerminalAssistantMessage && !assistantResponseStillInProgress; + terminalAssistantMessageIds.has(timelineEntry.message.id) && + !assistantResponseStillInProgress; nextRows.push({ kind: "message", @@ -2043,10 +2041,6 @@ export function deriveMessagesTimelineRows(input: { ? {} : { projectedItem: timelineEntry.projectedItem }), showAssistantMeta, - showAssistantTimestamp: - isTerminalAssistantMessage && - assistantResponseStillInProgress && - !timelineEntry.message.streaming, showAssistantCopyButton: showAssistantMeta, assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress, assistantTurnDiffSummary: @@ -2510,7 +2504,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.message === bm.message && a.projectedItem === bm.projectedItem && a.showAssistantMeta === bm.showAssistantMeta && - a.showAssistantTimestamp === bm.showAssistantTimestamp && a.showAssistantCopyButton === bm.showAssistantCopyButton && a.assistantCopyStreaming === bm.assistantCopyStreaming && a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index e70c32b064a3e..69da770f1f84b 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -79,6 +79,7 @@ export const ModelListRow = memo(function ModelListRow(props: { {/* The logo is decorative, so name the provider for screen readers. */} , {props.providerDisplayName}
    + {props.isSelected ?
- {props.jumpLabel ? {props.jumpLabel} : null} { event.stopPropagation(); props.onToggleFavorite(props.instanceId, props.slug); @@ -133,11 +132,7 @@ export const ModelListRow = memo(function ModelListRow(props: { {props.isFavorite ? "Remove from favorites" : "Add to favorites"} - {/* Last, and always reserved, so stars and the check keep fixed columns. */} -
); diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index 9b37c01149ed7..d2c9d86424de2 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -1,10 +1,18 @@ -import { type ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; -import { memo } from "react"; +import { + type EnvironmentId, + type ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { memo, useRef, useState } from "react"; import { InfoIcon, XIcon } from "lucide-react"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"; import { Button, InlineButton } from "../ui/button"; import { formatProviderDriverKindLabel } from "../../providerModels"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { RefreshIcon } from "../ui/refresh-icon"; function getIncompatibleVersion(status: ServerProvider) { const compatibility = status.compatibilityAdvisory; @@ -87,14 +95,35 @@ export function getProviderStatusMessage(status: ServerProvider): string { } export const ProviderStatusBanner = memo(function ProviderStatusBanner({ + environmentId, onDismiss, onOpenProviderSetup, status, }: { + environmentId: EnvironmentId; onDismiss: () => void; onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; status: ServerProvider | null; }) { + const canRefresh = useAtomValue(serverEnvironment.refreshProviders.permissionAtom(environmentId)); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + label: "Refresh provider status", + }); + const refreshingRef = useRef(false); + const [isRefreshing, setIsRefreshing] = useState(false); + + const refresh = async () => { + if (!status || !canRefresh || refreshingRef.current) return; + refreshingRef.current = true; + setIsRefreshing(true); + await refreshProviders({ + environmentId, + input: { instanceId: status.instanceId, refreshModels: true }, + }); + refreshingRef.current = false; + setIsRefreshing(false); + }; + if (!status || getProviderStatusBannerKey(status) === null) { return null; } @@ -122,17 +151,31 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ {title} - - }>{message} - - {message} - - - {onOpenProviderSetup && hasProviderSetup(status) ? ( - onOpenProviderSetup(status.instanceId)}> - Open provider setup - - ) : null} +
+ + }>{message} + + {message} + + +
+ {onOpenProviderSetup && hasProviderSetup(status) ? ( + onOpenProviderSetup(status.instanceId)}> + Open provider setup + + ) : null} + +
+
+ Pending files: {files.remainingFileCount} + {files.renderableFiles.map((file) => ( +
+ + {file.name}:{file.hunks.length ? "loaded" : "pending"} + + {file.type} +
+ ))} + + ); +} + +it("retains automatic and explicitly opened files through metadata insertion, refresh failure and retry", async () => { + state.patches.clear(); + const registry = AtomRegistry.make(); + using _registry = { [Symbol.dispose]: () => registry.dispose() }; + const tracked = source(["b.txt", "c.txt", "d.txt", "e.txt", "f.txt"], "tracked"); + const view = (source: ReviewDiffPreviewSource) => ( + + + + ); + await using screen = await render(view(tracked)); + await userEvent.click(screen.getByRole("button", { name: "Open f" })); + const atom = [...state.patches].find(([key]) => JSON.parse(key)[1] === "f.txt")![1]; + const loaded = AsyncResult.success({ + ...tracked, + diff: "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -1 +1 @@\n-old\n+new\n", + files: tracked.files!.filter((f) => f.path === "f.txt"), + }); + act(() => registry.set(atom, loaded)); + await expect.element(screen.getByText("f.txt:loaded")).toBeVisible(); + const automatic = [...state.patches].find(([key]) => JSON.parse(key)[1] === "e.txt")![1]; + act(() => + registry.set( + automatic, + AsyncResult.success({ + ...loaded.value, + diff: loaded.value.diff.replaceAll("f.txt", "e.txt"), + files: tracked.files!.filter((file) => file.path === "e.txt"), + }), + ), + ); + await expect.element(screen.getByText("e.txt:loaded")).toBeVisible(); + await screen.rerender( + view(source(["a-new.txt", ...tracked.files!.map((f) => f.path)], "complete")), + ); + await expect.element(screen.getByText("e.txt:loaded")).toBeVisible(); + await expect.element(screen.getByText("f.txt:loaded")).toBeVisible(); + act(() => + registry.set( + atom, + AsyncResult.fail(new Error("refresh failed"), { previousSuccess: Option.some(loaded) }), + ), + ); + await expect.element(screen.getByText("f.txt:loaded")).toBeVisible(); + act(() => registry.set(atom, loaded)); + await expect.element(screen.getByText("f.txt:loaded")).toBeVisible(); +}); + +it("preserves deletion and addition patches for a file replaced by a symlink", async () => { + state.patches.clear(); + const registry = AtomRegistry.make(); + using _registry = { [Symbol.dispose]: () => registry.dispose() }; + const metadata = source(["entry"], "type-change"); + await using screen = await render( + + + , + ); + const atom = [...state.patches.values()][0]!; + act(() => + registry.set( + atom, + AsyncResult.success({ + ...metadata, + diff: "diff --git a/entry b/entry\ndeleted file mode 100644\n--- a/entry\n+++ /dev/null\n@@ -1 +0,0 @@\n-before\ndiff --git a/entry b/entry\nnew file mode 120000\n--- /dev/null\n+++ b/entry\n@@ -0,0 +1 @@\n+target\n\\ No newline at end of file\n", + }), + ), + ); + await expect.element(screen.getByText("deleted", { exact: true })).toBeVisible(); + await expect.element(screen.getByText("new", { exact: true })).toBeVisible(); + await expect.element(screen.getByText("Pending files: 0")).toBeVisible(); +}); diff --git a/apps/web/src/components/diffs/useReviewFilePatches.ts b/apps/web/src/components/diffs/useReviewFilePatches.ts index 69dafc2dd7823..728f43433b010 100644 --- a/apps/web/src/components/diffs/useReviewFilePatches.ts +++ b/apps/web/src/components/diffs/useReviewFilePatches.ts @@ -5,6 +5,7 @@ import type { ReviewDiffPreviewInput, ReviewDiffPreviewSource, } from "@t3tools/contracts"; +import * as Option from "effect/Option"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Atom from "effect/reactivity/Atom"; import { useContext, useState } from "react"; @@ -16,7 +17,7 @@ const filePathCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base", }); -const DEFAULT_INDICES = [0, 1, 2, 3]; +const INITIAL_FILE_COUNT = 4; export function useReviewFilePatches({ environmentId, @@ -43,11 +44,19 @@ export function useReviewFilePatches({ environmentId, cwd, source?.kind, - source?.diffHash, + source?.baseRef, + source?.headRef, baseRef, ignoreWhitespace, ]); - const [requested, setRequested] = useState(() => ({ scope, indices: DEFAULT_INDICES })); + const files = sortedReviewFiles(source); + const automaticPaths = files.slice(0, INITIAL_FILE_COUNT).map((file) => file.path); + const [requested, setRequested] = useState(() => ({ scope, paths: automaticPaths })); + const previousPaths = requested.scope === scope ? requested.paths : []; + const paths = [...new Set([...previousPaths, ...automaticPaths])]; + if (requested.scope !== scope || paths.length !== previousPaths.length) { + setRequested({ scope, paths }); + } const queries = reviewFileQueries({ environmentId, cwd, @@ -67,7 +76,6 @@ export function useReviewFilePatches({ const query = queries.find(({ index }) => files[index]?.path === path)?.query; if (query) registry.refresh(query); }); - const files = sortedReviewFiles(source); const pendingIndex = files.findIndex((_, index) => { const patch = patches.get(index); return !patch || patch._tag === "Initial"; @@ -81,39 +89,46 @@ export function useReviewFilePatches({ : 0; const requestFiles = (indices: number[]) => setRequested((current) => { - const previous = current.scope === scope ? current.indices : [0, 1, 2, 3]; - const added = indices.filter((index) => !previous.includes(index)); - return added.length === 0 ? current : { scope, indices: [...previous, ...added] }; + const previous = current.scope === scope ? current.paths : []; + const added = indices.flatMap((index) => { + const path = files[index]?.path; + return path !== undefined && !previous.includes(path) ? [path] : []; + }); + return added.length === 0 ? current : { scope, paths: [...previous, ...added] }; }); // The first unsettled file already requested means a batch is in flight; wait for it. const loadNextFiles = () => { if (queries.some(({ index }) => index === settledFileCount)) return; requestFiles(Array.from({ length: 4 }, (_, index) => settledFileCount + index)); }; - const requestFile = (index: number) => requestFiles([index]); + const requestFile = (path: string) => + requestFiles([files.findIndex((file) => file.path === path)]); const renderableFiles = source - ? files.map((file, index): FileDiffMetadata => { + ? files.flatMap((file, index): FileDiffMetadata[] => { const result = patches.get(index); - if (result?._tag === "Success" && result.value.patch?.kind === "files") { - const loaded = result.value.patch.files.find( + const value = result && Option.getOrUndefined(AsyncResult.value(result)); + if (value?.patch?.kind === "files") { + const loaded = value.patch.files.filter( (candidate) => resolveFileDiffPath(candidate) === file.path, ); - if (loaded) return loaded; + if (loaded.length > 0) return loaded; } - return { - name: file.path, - ...(file.previousPath ? { prevName: file.previousPath } : {}), - type: file.previousPath ? "rename-changed" : "change", - hunks: [], - additionLines: [], - deletionLines: [], - additionCharacterCount: 0, - deletionCharacterCount: 0, - splitLineCount: 0, - unifiedLineCount: 0, - isPartial: true, - cacheKey: `${scope}:${file.path}:pending`, - }; + return [ + { + name: file.path, + ...(file.previousPath ? { prevName: file.previousPath } : {}), + type: file.previousPath ? "rename-changed" : "change", + hunks: [], + additionLines: [], + deletionLines: [], + additionCharacterCount: 0, + deletionCharacterCount: 0, + splitLineCount: 0, + unifiedLineCount: 0, + isPartial: true, + cacheKey: `${scope}:${file.path}:pending`, + }, + ]; }) : (preview?.kind === "files" ? preview.files : []).toSorted((a, b) => filePathCollator.compare(resolveFileDiffPath(a), resolveFileDiffPath(b)), @@ -136,23 +151,13 @@ export function useReviewFilePatches({ ] as const; }), ); - const readyFilePaths = new Set( - files - .filter((_, index) => { - const patch = patches.get(index); - return patch && patch._tag !== "Initial"; - }) - .map((file) => file.path), - ); return { - scope, fileStates, isPending: [...patches.values()].some((patch) => patch._tag === "Initial" || patch.waiting), retry, requestFile, - readyFilePaths, renderableFiles, - settledFileCount, + remainingFileCount: source ? files.length - settledFileCount : 0, loadNextFiles, }; } @@ -174,17 +179,19 @@ function reviewFileQueries({ ignoreWhitespace: boolean; previewInput: ReviewDiffPreviewInput | undefined; scope: string; - requested: { scope: string; indices: number[] }; + requested: { scope: string; paths: string[] }; }) { - const indices = requested.scope === scope ? requested.indices : DEFAULT_INDICES; const files = sortedReviewFiles(source); + const paths = new Set([ + ...files.slice(0, INITIAL_FILE_COUNT).map((file) => file.path), + ...(requested.scope === scope ? requested.paths : []), + ]); const queries = !environmentId || !cwd || !source ? [] - : indices - .filter((index) => index < files.length) - .map((index) => { - const file = files[index]!; + : files + .flatMap((file, index) => (paths.has(file.path) ? [{ file, index }] : [])) + .map(({ file, index }) => { const target: ReviewPatchTarget = { environmentId, input: { diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 98b34fbc00f89..89f0acc965079 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -5,9 +5,10 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; -const { refresh, assetUrlState } = vi.hoisted(() => ({ +const { refresh, assetUrlState, pdfOpens } = vi.hoisted(() => ({ refresh: vi.fn<() => Promise>(), assetUrlState: vi.fn<() => import("~/assets/assetUrls").AssetUrlState>(), + pdfOpens: [] as Array<(task: import("pdfjs-dist").PDFDocumentLoadingTask) => void>, })); vi.mock(import("~/assets/assetUrls"), async (original) => ({ @@ -15,6 +16,18 @@ vi.mock(import("~/assets/assetUrls"), async (original) => ({ useAssetUrlRefresh: () => refresh, useAssetUrlState: assetUrlState, })); +// Hands each document pdf.js opens to the PDF test, which awaits its load instead of a poll window. +vi.mock(import("./pdfDocument"), async (original) => { + const actual = await original(); + return { + ...actual, + openPdf: (bytes) => { + const task = actual.openPdf(bytes); + pdfOpens.shift()?.(task); + return task; + }, + }; +}); // The real source view highlights through a worker pool; the text it receives is what matters here. // Never load the original: its highlighter graph can outlast waitFor on a cold CI browser. vi.mock(import("./ReadOnlySourcePreview"), () => ({ @@ -55,6 +68,7 @@ import BinaryViewer from "./BinaryViewer"; import { decodeImagePreview } from "./decodeImagePreview"; import type { BinaryWorkerResult } from "./binaryPreviewProtocol"; import type { TextPreviewResult } from "./largeTextProtocol"; +import type { ReactElement } from "react"; // Real worker imports and WASM initialization can exceed waitFor's default second // when the full browser project runs concurrently on CI. @@ -2444,31 +2458,28 @@ function planningPdf( return new Blob([source], { type: "application/pdf" }); } -async function workspacePdfPreview() { +// Resolves once pdf.js has parsed the next document and every page size the viewer reads. +function nextPdfLoad() { + return new Promise((resolve) => + pdfOpens.push(resolve), + ).then(async (task) => { + const document = await task.promise; + await Promise.all( + Array.from({ length: document.numPages }, (_, index) => document.getPage(index + 1)), + ); + }); +} + +it("keeps a workspace PDF's page, offset and zoom when a new revision reloads it", async () => { assetUrlState.mockReset(); + pdfOpens.length = 0; const { WorkspaceBrowserPreview } = await import("./FilePreviewPanel"); - const native = vi.spyOn(navigator, "pdfViewerEnabled", "get").mockReturnValue(false); - const urls = (() => { - const original = URL.createObjectURL(planningPdf()); - const updated = URL.createObjectURL( - planningPdf(["Quarterly outlook", "Delivery milestones", "Updated budget"], 500), - ); - const shorter = URL.createObjectURL(planningPdf(["Quarterly outlook", "Updated milestones"])); - const shrunk = URL.createObjectURL( - planningPdf(["Quarterly outlook", "Small page", "Budget and risks"], 500, 200), - ); - const single = URL.createObjectURL(planningPdf(["Quarterly outlook"])); - return { - original, - updated, - shorter, - shrunk, - single, - [Symbol.dispose]: () => - [original, updated, shorter, shrunk, single].forEach(URL.revokeObjectURL), - }; - })(); - const threadId = ThreadId.make(urls.original); + using _native = vi.spyOn(navigator, "pdfViewerEnabled", "get").mockReturnValue(false); + const original = URL.createObjectURL(planningPdf()); + // A taller first page moves page 2, so only a page-relative restore lands on the same spot. + const updated = URL.createObjectURL( + planningPdf(["Quarterly outlook", "Delivery milestones", "Updated budget"], 500), + ); const preview = (src: string, mutation: string, path = "/repo/planning.pdf") => { assetUrlState.mockReturnValue({ _tag: "Success", @@ -2479,7 +2490,10 @@ async function workspacePdfPreview() {
); }; - return { - preview, - urls, - [Symbol.dispose]() { - native.mockRestore(); - urls[Symbol.dispose](); - }, + const reload = async (screen: Screen, element: ReactElement) => { + const loaded = nextPdfLoad(); + await screen.rerender(element); + await loaded; }; -} - -function pdfScroll(screen: Screen) { - const region = () => screen.container.querySelector('[role="region"]')!; - const canvas = (page: number) => - region().querySelector(`canvas[aria-label="Page ${page}"]`)!; - const offset = (page: number) => - region().getBoundingClientRect().top - canvas(page).getBoundingClientRect().top; - const scrollTo = (options: ScrollToOptions) => - act(async () => { - const node = region(); - const scrolled = new Promise((resolve) => - node.addEventListener("scroll", () => resolve(), { once: true }), + try { + const loaded = nextPdfLoad(); + await using screen = await render(preview(original, "one")); + await loaded; + await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); + using workers = vi.spyOn(window, "Worker"); + const region = () => screen.getByRole("region", { name: "Planning PDF" }).element(); + const offset = () => + region().getBoundingClientRect().top - + region().querySelector('canvas[aria-label="Page 2"]')!.getBoundingClientRect().top; + await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); + await screen.getByRole("button", { name: "Next page", exact: true }).click(); + await expect.element(screen.getByText("2 / 3 pages", { exact: true })).toBeVisible(); + const zoom = screen.container.textContent!.match(/\d+%/)![0]; + const top = region().scrollTop + 100; + await act(async () => { + const scrolled = new Promise((resolve) => + region().addEventListener("scroll", resolve, { once: true }), ); - node.scrollTo(options); + region().scrollTo({ top, left: 80 }); await scrolled; }); - return { region, canvas, offset, scrollTo }; -} + const savedOffset = offset(); -it("keeps workspace PDF scroll and zoom after reloads", async () => { - using fixture = await workspacePdfPreview(); - const { preview, urls } = fixture; - await using screen = await render(preview(urls.original, "one")); - await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); - await screen.getByRole("button", { name: "Next page", exact: true }).click(); - await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); - await screen.getByRole("button", { name: "Next page", exact: true }).click(); - await vi.waitFor(() => expect(screen.container.textContent).toContain("3 / 3 pages")); - for (let index = 0; index < 4; index++) { - await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); - } - const { region, offset, scrollTo } = pdfScroll(screen); - const zoom = screen.container.textContent!.match(/\d+%/)![0]; - const top = region().scrollTop; - await scrollTo({ top: top + 100, left: 80 }); - await vi.waitFor(() => { - expect(region().scrollTop).toBeCloseTo(top + 100, 0); - expect(region().scrollLeft).toBe(80); - }); - const savedOffset = offset(3); - await screen.rerender(preview(urls.updated, "two")); - await expect.element(screen.getByText("3 / 3 pages", { exact: true })).toBeVisible(); - await vi.waitFor(() => { - expect(screen.container.querySelector('[role="region"]')?.textContent).toContain( - "Updated budget", - ); + await reload(screen, preview(updated, "two")); + await expect.element(screen.getByText("2 / 3 pages", { exact: true })).toBeVisible(); expect(screen.container.textContent).toContain(zoom); expect(region().scrollLeft).toBe(80); - expect(offset(3)).toBeCloseTo(savedOffset, 0); - }); - await screen.getByRole("button", { name: "Previous page", exact: true }).click(); - await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); - await screen.getByRole("button", { name: "Fit width", exact: true }).click(); - await scrollTo({ top: region().scrollTop + 30 }); - const fitOffset = offset(2); - await screen.rerender(preview(urls.original, "fit-width")); - await expect.element(screen.getByText("2 / 3 pages", { exact: true })).toBeVisible(); - await vi.waitFor(() => { - expect(screen.container.textContent).toContain("Fit width"); - expect(region().scrollLeft).toBe(0); - expect(offset(2)).toBeCloseTo(fitOffset, 0); - }); - await screen.getByRole("button", { name: "Previous page", exact: true }).click(); - await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages")); - await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); - const firstPageZoom = screen.container.textContent!.match(/\d+%/)![0]; - await screen.rerender(preview(urls.original, "first-page")); - await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); - await vi.waitFor(() => expect(screen.container.textContent).toContain(firstPageZoom)); - await screen.getByRole("button", { name: "Next page", exact: true }).click(); - await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); - await screen.rerender(preview(urls.original, "one", "/repo/another.pdf")); - await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); - await vi.waitFor(() => { + expect(offset()).toBeCloseTo(savedOffset, 0); + expect(workers).not.toHaveBeenCalled(); + + await reload(screen, preview(updated, "two", "/repo/another.pdf")); + await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); expect(screen.container.textContent).toContain("Fit width"); expect(region().scrollTop).toBe(0); - expect(region().scrollLeft).toBe(0); - }); -}); - -it("clamps workspace PDF scroll when pages shrink or disappear", async () => { - using fixture = await workspacePdfPreview(); - const { preview, urls } = fixture; - await using screen = await render(preview(urls.original, "one")); - await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); - await screen.getByRole("button", { name: "Next page", exact: true }).click(); - await expect.element(screen.getByText("2 / 3 pages", { exact: true })).toBeVisible(); - for (let index = 0; index < 4; index++) { - await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); + } finally { + URL.revokeObjectURL(original); + URL.revokeObjectURL(updated); } - const { region, canvas, offset, scrollTo } = pdfScroll(screen); - const zoom = screen.container.textContent!.match(/\d+%/)![0]; - const secondPageTop = region().scrollTop; - await scrollTo({ top: secondPageTop + 600, left: 80 }); - await vi.waitFor(() => expect(region().scrollTop).toBeCloseTo(secondPageTop + 600, 0)); - await screen.rerender(preview(urls.shrunk, "smaller-page")); - await expect.element(screen.getByText("2 / 3 pages", { exact: true })).toBeVisible(); - await vi.waitFor(() => { - expect(screen.container.textContent).toContain(zoom); - expect(region().scrollLeft).toBe(80); - expect(offset(2)).toBeGreaterThan(0); - expect(offset(2)).toBeLessThan(canvas(2).getBoundingClientRect().height); - }); - await screen.rerender(preview(urls.shorter, "three")); - await expect.element(screen.getByText("2 / 2 pages", { exact: true })).toBeVisible(); - await vi.waitFor(() => { - expect(screen.container.querySelector('[role="region"]')?.textContent).toContain( - "Updated milestones", - ); - }); - await screen.rerender(preview(urls.single, "four")); - await expect.element(screen.getByText("1 / 1 pages", { exact: true })).toBeVisible(); - await screen.rerender(preview(urls.original, "five")); - await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); }); it("unwraps one text attachment without turning off the word wrap setting", async () => { diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index ddc77e5748f97..18f725c3ba68e 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -10,7 +10,6 @@ import { filePreviewReadErrorMessage, isMarkdownPreviewFile, resolveFilePreviewPath, - setMarkdownTaskChecked, shouldShowFileExplorer, workspaceAssetResource, } from "./filePreviewMode"; @@ -76,21 +75,6 @@ describe("isMarkdownPreviewFile", () => { }); }); -describe("setMarkdownTaskChecked", () => { - const markdown = "- [ ] First\n- [x] Second\n"; - - it("checks and unchecks the task marker at the supplied offset", () => { - expect(setMarkdownTaskChecked(markdown, 2, true)).toBe("- [x] First\n- [x] Second\n"); - expect(setMarkdownTaskChecked(markdown, 14, false)).toBe("- [ ] First\n- [ ] Second\n"); - expect(setMarkdownTaskChecked("1. [X] Ordered\n", 3, false)).toBe("1. [ ] Ordered\n"); - }); - - it("leaves the document unchanged for a stale or invalid marker offset", () => { - expect(setMarkdownTaskChecked(markdown, 0, true)).toBe(markdown); - expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown); - }); -}); - describe("resolveFilePreviewPath", () => { it.each([ ["/repo/project", null], diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 16b97d8b178de..0ea171c4320af 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -94,10 +94,10 @@ import { filePreviewReadErrorMessage, isMarkdownPreviewFile, resolveFilePreviewPath, - setMarkdownTaskChecked, shouldShowFileExplorer, workspaceAssetResource, } from "./filePreviewMode"; +import { setMarkdownTaskChecked } from "@t3tools/shared/markdownTaskList"; import { changeFileSaveSession, useFileSaveSession } from "./fileSaveSessions"; import { revisionedQuery } from "~/state/revisionedQuery"; import { diff --git a/apps/web/src/components/files/PdfViewer.tsx b/apps/web/src/components/files/PdfViewer.tsx index d5fddf8a69003..6a01bce2cfe90 100644 --- a/apps/web/src/components/files/PdfViewer.tsx +++ b/apps/web/src/components/files/PdfViewer.tsx @@ -21,6 +21,7 @@ import { pdfPageScrollTop, pdfPageText, pdfPageWidth, + pdfRestoredScrollTop, PDF_MAX_ZOOM, PDF_MIN_ZOOM, stepPdfZoom, @@ -253,15 +254,8 @@ export default function PdfViewer(props: { const restoreView = useEffectEvent(() => { const node = scroll.current; if (!pdf || !node) return; - const page = Math.min(initialPage, pdf.numPages - 1); node.scrollTo({ - top: - offsets[page]! - - 16 + - Math.min( - initialView.offset * (savedPosition ? width / savedPosition.width : 1), - Math.max(0, width * ratios[page]! - 1), - ), + top: pdfRestoredScrollTop(ratios, width, initialView, savedPosition?.width), left: initialView.left, }); setScrollTop(node.scrollTop); diff --git a/apps/web/src/components/files/fileEditorDismissal.browser.test.tsx b/apps/web/src/components/files/fileEditorDismissal.browser.test.tsx index 7f7d6601c68ca..efc7d0fcc82e2 100644 --- a/apps/web/src/components/files/fileEditorDismissal.browser.test.tsx +++ b/apps/web/src/components/files/fileEditorDismissal.browser.test.tsx @@ -1,3 +1,4 @@ +import { DEFAULT_THEMES, preloadHighlighter } from "@pierre/diffs"; import { Editor, type EditorFactory } from "@pierre/diffs/edit"; import { EditProvider, File } from "@pierre/diffs/react"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -12,6 +13,8 @@ describe("installFileEditorDismissal", () => { let editor: Editor<"file" | "file-diff", undefined, undefined> | undefined; const createEditor: EditorFactory = (...args) => (editor = new Editor(...args)); + // File paints nothing until the shared highlighter has loaded; once it has, mount renders synchronously. + await preloadHighlighter({ themes: Object.values(DEFAULT_THEMES), langs: ["typescript"] }); await using screen = await render( <>

Assistant reply

@@ -23,17 +26,14 @@ describe("installFileEditorDismissal", () => { , ); const shadowRoot = () => screen.container.querySelector("diffs-container")?.shadowRoot; - const content = await vi.waitFor(() => { - const element = shadowRoot()?.querySelector("[data-content]"); - expect(element).toBeTruthy(); - return element!; - }); + const content = shadowRoot()?.querySelector("[data-content]"); + expect(content).toBeTruthy(); editor!.setSelections([ { start: { line: 0, character: 8 }, end: { line: 0, character: 8 }, direction: "none" }, ]); const isMac = isMacPlatform(navigator.platform); - content.dispatchEvent( + content!.dispatchEvent( new KeyboardEvent("keydown", { key: "f", code: "KeyF", diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 3777fad423ff5..4fca2af1c183b 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -40,23 +40,6 @@ export function shouldShowFileExplorer(input: { return input.explorerOpen || input.relativePath === null; } -export function setMarkdownTaskChecked( - markdown: string, - markerOffset: number, - checked: boolean, -): string { - if ( - markerOffset < 0 || - markdown[markerOffset] !== "[" || - !/[ xX]/.test(markdown[markerOffset + 1] ?? "") || - markdown[markerOffset + 2] !== "]" - ) { - return markdown; - } - - return `${markdown.slice(0, markerOffset + 1)}${checked ? "x" : " "}${markdown.slice(markerOffset + 2)}`; -} - /** * The server only signs `workspace-file` assets below the workspace root, so a * file opened from outside it, such as an absolute link in the thread, goes diff --git a/apps/web/src/components/files/fileSaveSessions.browser.test.tsx b/apps/web/src/components/files/fileSaveSessions.browser.test.tsx index aff76479b7410..70456e2d92bb7 100644 --- a/apps/web/src/components/files/fileSaveSessions.browser.test.tsx +++ b/apps/web/src/components/files/fileSaveSessions.browser.test.tsx @@ -1,5 +1,5 @@ import { render, fakeTimers } from "../../../test/render"; -import { EnvironmentId } from "@t3tools/contracts"; +import { type AuthSessionState, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { StrictMode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -12,10 +12,18 @@ vi.mock(import("@t3tools/client-runtime/state/runtime"), async (original) => ({ ...(await original()), runAtomCommand: runAtomCommand as never, })); -vi.mock(import("~/state/session"), async (original) => ({ - ...(await original()), - readEnvironmentScope: () => true, -})); +// The real session atom boots the connection runtime, whose endless discovery poll +// would keep vi.runAllTimersAsync from ever draining once its timers are faked. +vi.mock(import("~/state/session"), async (original) => { + const actual = await original(); + const reactivity = await import("effect/reactivity"); + const signedOut = reactivity.Atom.make(reactivity.AsyncResult.initial()); + return { + ...actual, + environmentSession: { ...actual.environmentSession, sessionStateAtom: () => signedOut }, + readEnvironmentScope: () => true, + }; +}); vi.mock(import("./projectFilesQueryState"), async (original) => ({ ...(await original()), confirmProjectFileQueryData: () => true, diff --git a/apps/web/src/components/files/office/Spreadsheet.tsx b/apps/web/src/components/files/office/Spreadsheet.tsx index 6faa42d924c08..7bea259b9f07b 100644 --- a/apps/web/src/components/files/office/Spreadsheet.tsx +++ b/apps/web/src/components/files/office/Spreadsheet.tsx @@ -1,3 +1,4 @@ +import * as Schema from "effect/Schema"; import { observeResize } from "~/lib/observeResize"; import { useEffect, useRef, useState } from "react"; import { @@ -33,6 +34,8 @@ import { } from "./sheet-paint"; import { externalUrl } from "./links"; +const isSupersededError = Schema.is(SupersededError); + type Client = ReturnType>; type Opened = { engine: Client; @@ -264,7 +267,7 @@ function SpreadsheetGrid(props: { schedule(); }) .catch((cause: unknown) => { - if (!(cause instanceof SupersededError) && !controller.signal.aborted) + if (!isSupersededError(cause) && !controller.signal.aborted) setNotice("These cells could not be drawn."); }); } diff --git a/apps/web/src/components/files/office/worker-rpc.ts b/apps/web/src/components/files/office/worker-rpc.ts index c5471cea87c82..2be77ea89388f 100644 --- a/apps/web/src/components/files/office/worker-rpc.ts +++ b/apps/web/src/components/files/office/worker-rpc.ts @@ -3,6 +3,8 @@ // two ends of that worker's protocol: typed calls by method name, a key that lets a newer call replace a queued one // (as the markdown worker's queue does), transferable payloads, and a worker that fails every pending call when it dies. +import * as Schema from "effect/Schema"; + /** What one method takes and returns. */ export type Method = { readonly input: Input; readonly output: Output }; @@ -43,10 +45,14 @@ export class Transfer { } /** A call that a newer call with the same key replaced before it ran. */ -export class SupersededError extends Error {} +export class SupersededError extends Schema.TaggedError()("SupersededError", { + message: Schema.String, +}) {} /** A call made after the worker failed or was closed, or pending when that happened. */ -class WorkerClosedError extends Error {} +class WorkerClosedError extends Schema.TaggedError()("WorkerClosedError", { + message: Schema.String, +}) {} type CallOptions = { /** A newer call with the same key replaces this one while it waits to run. */ @@ -93,10 +99,10 @@ export function createWorkerClient(worker: Worker) { }); worker.addEventListener("error", (event) => - fail(new WorkerClosedError(event.message || "The Office engine stopped")), + fail(new WorkerClosedError({ message: event.message || "The Office engine stopped" })), ); worker.addEventListener("messageerror", () => - fail(new WorkerClosedError("The Office engine sent an unreadable reply")), + fail(new WorkerClosedError({ message: "The Office engine sent an unreadable reply" })), ); return { @@ -108,7 +114,7 @@ export function createWorkerClient(worker: Worker) { if (state.failure) return Promise.reject(state.failure); if (options.signal?.aborted) - return Promise.reject(new WorkerClosedError("The call was aborted")); + return Promise.reject(new WorkerClosedError({ message: "The call was aborted" })); const id = ++state.next; @@ -116,16 +122,16 @@ export function createWorkerClient(worker: Worker) { const timeout = setTimeout( () => fail( - new WorkerClosedError( - "This document took too long to preview. Open it in another app.", - ), + new WorkerClosedError({ + message: "This document took too long to preview. Open it in another app.", + }), ), 30_000, ); const abort = () => { clearTimeout(timeout); pending.delete(id); - reject(new WorkerClosedError("The call was aborted")); + reject(new WorkerClosedError({ message: "The call was aborted" })); worker.postMessage({ cancel: id } satisfies Cancel, []); }; @@ -138,7 +144,7 @@ export function createWorkerClient(worker: Worker) { if (!response.ok) return reject( response.superseded - ? new SupersededError(response.message) + ? new SupersededError({ message: response.message }) : new Error(response.message), ); @@ -158,7 +164,7 @@ export function createWorkerClient(worker: Worker) { }, /** Stops the worker, which frees its engine's memory, and fails every pending call. */ close() { - fail(new WorkerClosedError("The Office engine was closed")); + fail(new WorkerClosedError({ message: "The Office engine was closed" })); }, }; } diff --git a/apps/web/src/components/files/pdfDocument.ts b/apps/web/src/components/files/pdfDocument.ts index 2cc5493718b4c..32e8aefbe9028 100644 --- a/apps/web/src/components/files/pdfDocument.ts +++ b/apps/web/src/components/files/pdfDocument.ts @@ -1,4 +1,4 @@ -import { getDocument, GlobalWorkerOptions } from "pdfjs-dist"; +import { getDocument, GlobalWorkerOptions, PDFWorker } from "pdfjs-dist"; import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; import fallbackUrl from "pdfjs-dist/wasm/openjpeg_nowasm_fallback.js?url"; @@ -31,8 +31,13 @@ export class BinaryDataFactory { } } +// Without a shared worker, pdf.js starts a worker and recompiles its 1.3 MB script for every document. +let worker: PDFWorker | undefined; + export function openPdf(bytes: ArrayBuffer) { + worker ??= new PDFWorker(); return getDocument({ + worker, data: new Uint8Array(bytes), useSystemFonts: true, useWorkerFetch: false, diff --git a/apps/web/src/components/files/pdfLayout.test.ts b/apps/web/src/components/files/pdfLayout.test.ts index 5a57be1de95c0..e06dc188e447c 100644 --- a/apps/web/src/components/files/pdfLayout.test.ts +++ b/apps/web/src/components/files/pdfLayout.test.ts @@ -7,6 +7,7 @@ import { pdfPageScrollTop, pdfPageText, pdfPageWidth, + pdfRestoredScrollTop, stepPdfZoom, visiblePdfPages, } from "./pdfLayout"; @@ -72,6 +73,14 @@ describe("mixed-height page layout", () => { it("scales offsets with the page width when zoomed", () => { expect(pdfPageOffsets(ratios, width * 2)).toEqual([16, 312, 1912, 2540]); }); + + it("restores a saved spot scaled to the new width and kept inside pages that remain", () => { + expect(pdfRestoredScrollTop(ratios, width, { page: 1, offset: 100 })).toBe(256); + expect(pdfRestoredScrollTop(ratios, width * 2, { page: 1, offset: 100 }, width)).toBe(496); + // A page that shrank keeps the reader on its last pixel; a removed page falls back to the last. + expect(pdfRestoredScrollTop(ratios, width, { page: 2, offset: 600 })).toBe(964 + 305); + expect(pdfRestoredScrollTop(ratios.slice(0, 2), width, { page: 2, offset: 0 })).toBe(156); + }); }); describe("pdfPageText", () => { diff --git a/apps/web/src/components/files/pdfLayout.ts b/apps/web/src/components/files/pdfLayout.ts index 451af886fb67b..0ac3832c70bc4 100644 --- a/apps/web/src/components/files/pdfLayout.ts +++ b/apps/web/src/components/files/pdfLayout.ts @@ -50,6 +50,22 @@ export function pdfPageScrollTop(offsets: readonly number[], index: number) { return Math.max(0, (offsets[index] ?? PDF_PAGE_GAP) - PDF_PAGE_GAP); } +/** A saved offset was taken at `savedWidth`; a reloaded document can have fewer or shorter pages. */ +export function pdfRestoredScrollTop( + ratios: readonly number[], + width: number, + view: { page: number; offset: number }, + savedWidth = width, +) { + const page = Math.min(view.page, ratios.length - 1); + const offsets = pdfPageOffsets(ratios, width); + return ( + offsets[page]! - + PDF_PAGE_GAP + + Math.min((view.offset * width) / savedWidth, Math.max(0, width * ratios[page]! - 1)) + ); +} + export function pdfPageText( items: readonly ({ str: string; hasEOL: boolean } | { type: string })[], ) { diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index b965b0c72b70d..3aea3ec75a6dd 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -82,9 +82,11 @@ export function useMediaActions(source: MediaActionSource) { export function MediaActions({ source, children, + showTooltip = true, }: { source: MediaActionSource; children: ReactElement; + showTooltip?: boolean; }) { const { save, copyImage, canReadMedia, assertCanReadMedia } = useMediaActions(source); const [tooltipOpen, setTooltipOpen] = useState(false); @@ -173,7 +175,7 @@ export function MediaActions({ }; return ( - + ; + readonly loadedFiles: ReadonlyArray["files"][number]>; + readonly sourceFileStats: ReadonlyMap>; +}; +const diffModels = new WeakMap, Map>(); +const patchHashes = new WeakMap(); + +// Hashing, ordering and counting every file of a huge pull request costs a second, and the tab +// renders far more often than its slices change, so the model is kept per slice list. +function diffModel( + slices: ReadonlyArray, + keyPrefix: string, + ignoreWhitespace: boolean, +): DiffModel { + let models = diffModels.get(slices); + if (models === undefined) diffModels.set(slices, (models = new Map())); + const cached = models.get(keyPrefix); + if (cached !== undefined) return cached; + const parsedSlices = slices.map((slice) => { + let hash = patchHashes.get(slice); + if (hash === undefined) patchHashes.set(slice, (hash = fnv1a32(slice.patch))); + // The cache key carries the theme, which the tokenizer caches against, and the patch's + // hash: a refreshed page reuses its cursor, so position alone would return a stale parse. + return parseSlice(slice, `${keyPrefix}:${slice.cursor ?? "first"}:${hash}`, ignoreWhitespace); + }); + const model: DiffModel = { + parsedSlices, + loadedFiles: orderDiffFilesLikeTree( + parsedSlices.flatMap((parsed) => + parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], + ), + ), + sourceFileStats: new Map( + parsedSlices.flatMap((parsed) => + parsed?.kind === "files" + ? parsed.files.map( + (file, index) => + [ + buildFileDiffRenderKey(file), + getDiffLineStat([parsed.sourceFiles[index] ?? file]), + ] as const, + ) + : [], + ), + ), + }; + models.set(keyPrefix, model); + return model; +} + +const threadsByPathCache = new WeakMap< + ReadonlyArray, + ReadonlyMap> +>(); + +function reviewThreadsByPath(threads: ReadonlyArray) { + let byPath = threadsByPathCache.get(threads); + if (byPath === undefined) { + const grouped = new Map(); + for (const thread of threads) { + const list = grouped.get(thread.path); + if (list) list.push(thread); + else grouped.set(thread.path, [thread]); + } + threadsByPathCache.set(threads, (byPath = grouped)); + } + return byPath; +} + interface FileViewedToggleState { readonly enabled: boolean; readonly isViewed: (path: string) => boolean; @@ -491,20 +561,10 @@ function PullRequestCodeTab({ // Agent context is local, so it does not require host comment permission. const canSelectLines = commit === null && (review.inlineComment || onAddToAgentSelection !== undefined); - // The cache key carries the theme, which is what the tokenizer caches against, so a theme - // change is still a fresh parse. The patch's own hash is part of it too: a refreshed page - // reuses its cursor, and a key of position alone would hand back the parse it replaced. - const parsedSlices = loadedSlices.map((slice) => - parseSlice( - slice, - `pull-request:${scopeKey}:${resolvedTheme}:${ignoreWhitespace}:${slice.cursor ?? "first"}:${fnv1a32(slice.patch)}`, - ignoreWhitespace, - ), - ); - const loadedFiles = orderDiffFilesLikeTree( - parsedSlices.flatMap((parsed) => - parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], - ), + const { parsedSlices, loadedFiles, sourceFileStats } = diffModel( + loadedSlices, + `pull-request:${scopeKey}:${resolvedTheme}:${ignoreWhitespace}`, + ignoreWhitespace, ); const testFileCount = loadedFiles.filter((file) => isTestChangedFile(resolveFileDiffPath(file)), @@ -523,33 +583,16 @@ function PullRequestCodeTab({ slice.omittedFileStats.map((file) => [file.path, file] as const), ), ); - const sourceFileStats = new Map( - parsedSlices.flatMap((parsed) => - parsed?.kind === "files" - ? parsed.files.map( - (file, index) => - [ - buildFileDiffRenderKey(file), - getDiffLineStat([parsed.sourceFiles[index] ?? file]), - ] as const, - ) - : [], - ), - ); const fileTreeEntries = diffFileTreeEntries(files); + const threadsByPath = reviewThreadsByPath(detail.reviewThreads); // File membership does not guarantee a rendered hunk for the thread. const placedThreadIds = (() => { const placed = new Set(); // Commit diffs use different line coordinates; list conversations separately. if (commit !== null) return placed; for (const file of files) { - const path = resolveFileDiffPath(file); - for (const thread of detail.reviewThreads) { - if ( - thread.path === path && - thread.line !== null && - isLineInFileDiff(file, thread.side, thread.line) - ) { + for (const thread of threadsByPath.get(resolveFileDiffPath(file)) ?? []) { + if (thread.line !== null && isLineInFileDiff(file, thread.side, thread.line)) { placed.add(thread.id); } } @@ -576,9 +619,8 @@ function PullRequestCodeTab({ return created; }; - for (const thread of detail.reviewThreads) { - if (thread.path !== path || thread.line === null) continue; - if (!placedThreadIds.has(thread.id)) continue; + for (const thread of threadsByPath.get(path) ?? []) { + if (thread.line === null || !placedThreadIds.has(thread.id)) continue; groupAt(thread.side, thread.line).threads.push(thread); } if (commit === null) { diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 868b90cb87842..51595e705d774 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -68,6 +68,7 @@ export function PullRequestMarkdown({ threadRef, lineBreaks, className, + onTaskListChange, }: { text: string; cwd: string; @@ -76,6 +77,8 @@ export function PullRequestMarkdown({ threadRef?: ScopedThreadRef | null; lineBreaks?: boolean; className?: string; + /** `markerOffset` is into `text`. Checkboxes stay read-only without it. */ + onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; }) { const context = useContext(PullRequestMarkdownContext); // Coder: GitLab upload links in the body split against the repository. @@ -103,6 +106,15 @@ export function PullRequestMarkdown({ {...(context ? { extraRemarkPlugins: context.remarkPlugins } : {})} lineBreaks={lineBreaks ?? context?.lineBreaks ?? false} sourceControlMedia + onTaskListChange={ + onTaskListChange && segment.kind === "markdown" + ? ({ markerOffset, checked }) => + onTaskListChange({ + markerOffset: segment.sourceOffset + markerOffset, + checked, + }) + : undefined + } /> ); } diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx index ba8cb353e8d12..98db15d1429c7 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx @@ -3,10 +3,39 @@ import { EnvironmentId, ProjectId, type PullRequestDetailView } from "@t3tools/c import { expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; +const state = vi.hoisted(() => ({ update: vi.fn(), canUpdate: true })); + vi.mock(import("~/state/use-atom-command"), async (original) => ({ ...(await original()), - useAtomCommand: (() => vi.fn()) as never, + useAtomCommand: (() => state.update) as never, +})); +vi.mock(import("~/state/session"), async (original) => ({ + ...(await original()), + readEnvironmentScope: () => true, + useEnvironmentScope: () => true, })); +vi.mock(import("~/state/pullRequests"), async (original) => { + const actual = await original(); + const { Atom } = await import("effect/reactivity"); + const allowed = Atom.make(true); + const denied = Atom.make(false); + return { + ...actual, + pullRequestEnvironment: { + ...actual.pullRequestEnvironment, + update: { + ...actual.pullRequestEnvironment.update, + permissionAtom: () => (state.canUpdate ? allowed : denied), + }, + }, + }; +}); + +function setup() { + state.update.mockReset(); + state.update.mockResolvedValue({ _tag: "Success", value: undefined }); + state.canUpdate = true; +} import { act, render as renderUi } from "../../../test/render"; import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; @@ -95,7 +124,9 @@ async function render(value = detail) { }); } -it("keeps an unsaved description when collapsed and reopened", async () => { +it("keeps an unsaved description when collapsed and reopened, and after a failed save", async () => { + setup(); + state.update.mockResolvedValue({ _tag: "Failure", cause: new Error("offline") }); await using screen = await render(); await act(async () => screen.container.querySelector('[aria-label="Edit description"]')!.click(), @@ -107,4 +138,50 @@ it("keeps an unsaved description when collapsed and reopened", async () => { expect(screen.expanded("Description")).toBe("false"); await screen.click("Description"); expect(screen.container.querySelector("textarea")!.value).toBe("Unsaved description"); + + await userEvent.click(screen.getByRole("button", { name: "Save" })); + expect(state.update).toHaveBeenCalledTimes(1); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + expect(screen.container.querySelector("textarea")!.value).toBe("Unsaved description"); +}); + +it("ticks a description checkbox placed after an attachment and edits from the ticked body", async () => { + setup(); + let finishSave = (_result: unknown) => {}; + state.update.mockReturnValueOnce(new Promise((resolve) => (finishSave = resolve))); + const body = "Intro\n\nhttps://example.com/shot.png\n\n## Test plan\n- [ ] First\n- [ ] Second"; + const ticked = body.replace("- [ ] Second", "- [x] Second"); + await using screen = await render({ ...detail, body, observedAt: 1 }); + const boxes = () => screen.container.querySelectorAll('input[type="checkbox"]'); + expect([...boxes()].map((box) => box.disabled)).toEqual([false, false]); + + await userEvent.click(boxes()[1]!); + expect(state.update).toHaveBeenCalledWith({ + environmentId: "environment", + input: expect.objectContaining({ number: 1, body: ticked }), + }); + expect([...boxes()].map((box) => box.checked)).toEqual([false, true]); + await act(async () => finishSave({ _tag: "Success", value: undefined })); + + // The host has not been read since the save, so the editor starts from the ticked body. + await act(async () => + screen.container.querySelector('[aria-label="Edit description"]')!.click(), + ); + expect(screen.container.querySelector("textarea")!.value).toBe(ticked); +}); + +it("leaves description checkboxes read-only without the right to edit it", async () => { + setup(); + const body = "- [ ] First"; + { + await using screen = await render({ ...detail, body, viewer: "someone-else" }); + expect( + screen.container.querySelector('input[type="checkbox"]')!.disabled, + ).toBe(true); + } + state.canUpdate = false; + await using screen = await render({ ...detail, body }); + expect(screen.container.querySelector('input[type="checkbox"]')!.disabled).toBe( + true, + ); }); diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index f8084aa550ad3..2ffef724ab782 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { capitalizeTerm, sourceControlClients, @@ -73,7 +74,12 @@ import { import { canEditPullRequestChangeRequest, canEditPullRequestComment, + descriptionDraftInForce, + settleDescriptionSave, + startDescriptionSave, + type DescriptionDraft, } from "@t3tools/client-runtime/pull-request-editing"; +import { setMarkdownTaskChecked } from "@t3tools/shared/markdownTaskList"; import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestCommentBody } from "./PullRequestCommentBody"; import { PullRequestMoreReplies } from "./PullRequestMoreReplies"; @@ -489,8 +495,23 @@ export function PullRequestSummaryTab({ const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, { reportFailure: false, }); - const [bodyScope, setBodyScope] = useState(null); - const [bodySaving, setBodySaving] = useState(false); + const canUpdate = useAtomValue(pullRequestEnvironment.update.permissionAtom(environmentId)); + const canEditDescription = canUpdate && canEditPullRequestChangeRequest(detail); + // The editor keeps the body it opened from, so a failed save cannot reset what was typed. + const [bodyScope, setBodyScope] = useState<{ + readonly url: string; + readonly body: string; + } | null>(null); + const [descriptionDraft, setDescriptionDraft] = useState(null); + const descriptionDraftKept = descriptionDraftInForce( + descriptionDraft, + detail.url, + detail.observedAt, + ); + if (descriptionDraftKept !== descriptionDraft) setDescriptionDraft(descriptionDraftKept); + const description = descriptionDraftKept?.body ?? detail.body; + const descriptionSaving = (descriptionDraftKept?.saves ?? 0) > 0; + const descriptionSavesRunning = useRef(0); // Hosts can reuse comment ids across pull requests. const [commentScope, setCommentScope] = useState<{ readonly pullRequest: string; @@ -499,17 +520,26 @@ export function PullRequestSummaryTab({ const [commentSaving, setCommentSaving] = useState(false); const editingCommentId = commentScope?.pullRequest === detail.url ? commentScope.commentId : null; - const saveBody = async (body: string) => { - if (bodySaving) return; - setBodySaving(true); + // Checkbox ticks and the editor share one draft, so each starts from the other's latest body. + const saveDescription = async (body: string) => { + const key = detail.url; + setDescriptionDraft((draft) => startDescriptionSave(draft, detail, body)); + descriptionSavesRunning.current += 1; const result = await update({ environmentId, input: { ...reference, body } }); - setBodySaving(false); - if (result._tag === "Failure") { - toastManager.add({ type: "error", title: "Could not save the description" }); - return; - } - setBodyScope(null); - onRefresh(); + descriptionSavesRunning.current -= 1; + const saved = result._tag === "Success"; + setDescriptionDraft((draft) => settleDescriptionSave(draft, key, saved)); + if (!saved) toastManager.add({ type: "error", title: "Could not save the description" }); + if (descriptionSavesRunning.current === 0) onRefresh(); + return saved; + }; + const saveBody = async (body: string) => { + if (descriptionSaving) return; + if (await saveDescription(body)) setBodyScope(null); + }; + const toggleTask = ({ markerOffset, checked }: { markerOffset: number; checked: boolean }) => { + const body = setMarkdownTaskChecked(description, markerOffset, checked); + if (body !== description) void saveDescription(body); }; const commentEditing: CommentEditing = { @@ -758,16 +788,16 @@ export function PullRequestSummaryTab({
- {bodyScope === detail.url ? ( + {bodyScope?.url === detail.url ? ( void saveBody(body)} onCancel={() => setBodyScope(null)} /> @@ -775,15 +805,16 @@ export function PullRequestSummaryTab({
0 ? detail.body : "_No description provided._"} + text={description.trim().length > 0 ? description : "_No description provided._"} cwd={detail.workspaceRoot} environmentId={environmentId} threadRef={threadRef} + onTaskListChange={canEditDescription ? toggleTask : undefined} /> - {canEditPullRequestChangeRequest(detail) ? ( + {canEditDescription ? ( setBodyScope(detail.url)} + onClick={() => setBodyScope({ url: detail.url, body: description })} /> ) : null}
diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts index 6b3f22a2a73a8..1263c675e05b7 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts @@ -39,21 +39,6 @@ export function updateProviderIdentityDraft( }; } -export function deriveAvailableInstanceId( - derive: (label: string) => string, - label: string, - existing: ReadonlySet, -): string { - const base = derive(label); - if (!base || !existing.has(base)) return base; - - for (let suffix = 2; ; suffix += 1) { - const suffixText = `_${suffix}`; - const candidate = `${base.slice(0, 64 - suffixText.length)}${suffixText}`; - if (!existing.has(candidate)) return candidate; - } -} - export function isConfiguredAcpRegistryAgent( instances: Readonly>, agentId: string, diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts index 8abbb1174f295..7ebfadc111896 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { - deriveAvailableInstanceId, getProviderIdentityDraft, resolveAcpRegistryWizardNavigation, resolveWizardNavigation, @@ -50,18 +49,6 @@ describe("ACP Registry wizard", () => { ).toEqual({ kind: "navigate", step: 1 }); }); - it("derives a collision-free instance id without exceeding the slug limit", () => { - const derive = (label: string) => `acpRegistry_${label}`; - const existing = new Set(["acpRegistry_gemini", "acpRegistry_gemini_2"]); - - expect(deriveAvailableInstanceId(derive, "gemini", existing)).toBe("acpRegistry_gemini_3"); - - const longBase = `acpRegistry_${"a".repeat(48)}`; - expect(deriveAvailableInstanceId(() => longBase, "ignored", new Set([longBase]))).toHaveLength( - 62, - ); - }); - it("keeps registry-prefilled identity separate from other drivers", () => { const registryDrafts = updateProviderIdentityDraft({}, "acpRegistry", { label: "Gemini CLI", diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 02a111745f259..8a50c570dc976 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -3,6 +3,10 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { CheckIcon } from "lucide-react"; import { useState } from "react"; +import { + deriveAvailableInstanceId, + deriveInstanceId, +} from "@t3tools/client-runtime/provider-instance-ids"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { ProviderClientDefinition } from "@t3tools/provider-core/client"; import { @@ -33,7 +37,6 @@ import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSe import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard"; import { ADD_PROVIDER_WIZARD_STEPS, - deriveAvailableInstanceId, resolveWizardNavigation, type ProviderIdentityDraft, type WizardNavigation, @@ -46,21 +49,6 @@ import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps // or post-create sign-in step; API credentials are configured in the workspace. All four are // first-class, so upstream's Show more fold (#25753) has nothing to hold. -/** Limit the slug to 48 chars so the composed ProviderInstanceId fits its 64-char cap. */ -function slugifyLabel(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, "") - .slice(0, 48); -} - -function deriveInstanceId(driver: ProviderDriverKind, label: string): string { - const slug = slugifyLabel(label); - return slug ? `${driver}_${slug}` : ""; -} - const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); const DEFAULT_DRIVER_OPTION = providerClients.definitions[0]!; diff --git a/apps/web/src/components/settings/FoldedSettingsSection.tsx b/apps/web/src/components/settings/FoldedSettingsSection.tsx index ce68f5bedb179..dd9c6e5de15d9 100644 --- a/apps/web/src/components/settings/FoldedSettingsSection.tsx +++ b/apps/web/src/components/settings/FoldedSettingsSection.tsx @@ -12,6 +12,7 @@ export function FoldedSettingsSection({ summary, control, headerPlacement = "inside", + collapsedContent, children, }: { readonly id: string; @@ -19,6 +20,8 @@ export function FoldedSettingsSection({ readonly summary?: string | null; readonly control?: ReactNode; readonly headerPlacement?: "inside" | "outside"; + /** Shown under the header only while folded. */ + readonly collapsedContent?: ReactNode; readonly children: ReactNode; }) { const [open, setOpen] = useState(false); @@ -82,6 +85,9 @@ export function FoldedSettingsSection({ {control ?
{control}
: null}
+ {!open && collapsedContent ? ( +
{collapsedContent}
+ ) : null}
{children} diff --git a/apps/web/src/components/settings/OpenSourceLicenses.tsx b/apps/web/src/components/settings/OpenSourceLicenses.tsx index df311db516c5a..087227384230b 100644 --- a/apps/web/src/components/settings/OpenSourceLicenses.tsx +++ b/apps/web/src/components/settings/OpenSourceLicenses.tsx @@ -1,7 +1,7 @@ import { ChevronRightIcon, ExternalLinkIcon, SearchIcon } from "lucide-react"; import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; +import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { FetchHttpClient, HttpClient } from "effect/http"; @@ -22,9 +22,10 @@ import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group" import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; -class LicenseManifestLoadError extends Data.TaggedError("LicenseManifestLoadError")<{ - readonly message: string; -}> {} +class LicenseManifestLoadError extends Schema.TaggedError()( + "LicenseManifestLoadError", + { message: Schema.String }, +) {} // Not keepAlive: a failed load must not stick for the session, so each visit to the page loads // the manifest again (a static asset the HTTP cache serves). diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts index 6f8ef830daf5c..8d0ad779842cf 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { planProjectRemoval } from "@t3tools/client-runtime/state/projects"; describe("project removal confirmation", () => { @@ -16,18 +16,32 @@ describe("project removal confirmation", () => { environmentLabel: "Remote", }; - it("limits a checkout's thread count to its environment and project", () => { + const parentId = ThreadId.make("parent"); + const listed = { rootThreadId: parentId, parentThreadId: null, relationshipToParent: null }; + const child = { rootThreadId: parentId, parentThreadId: parentId }; + + it("limits a checkout's thread count to its listed threads in that environment and project", () => { const plan = planProjectRemoval({ members: [local], groupMemberCount: 2, hasOtherMembers: false, displayName: "Repo", threads: [ - { environmentId: local.environmentId, projectId: local.id }, - { environmentId: remote.environmentId, projectId: remote.id }, + { environmentId: local.environmentId, projectId: local.id, lineage: listed }, + { + environmentId: local.environmentId, + projectId: local.id, + lineage: { ...child, relationshipToParent: "subagent" }, + }, + { + environmentId: local.environmentId, + projectId: local.id, + lineage: { ...child, relationshipToParent: "fork", sideChat: true }, + }, + { environmentId: remote.environmentId, projectId: remote.id, lineage: listed }, ], }); - expect(plan.projectThreads).toHaveLength(1); + expect(plan.projectThreads).toHaveLength(3); expect(plan.confirmationMessage).toBe( 'Remove checkout "Repo" and delete its 1 thread?\nPath: /work/repo\nEnvironment: Local\nThose threads and any archived threads are deleted from T3 Code. Provider sessions are kept.\nOther entries in this grouped project are unaffected.\nThis action cannot be undone.', ); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index d0553965d9793..ae86a190cc917 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -6,7 +6,7 @@ import { useEnvironmentsWithScope } from "../../state/session"; import { mapAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { planProjectRemoval } from "@t3tools/client-runtime/state/projects"; -import { type EnvironmentId } from "@t3tools/contracts"; +import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { checkProjectAccess, reportProjectFailure, @@ -19,8 +19,12 @@ import { lazy, Suspense, useRef, useState, type ReactNode } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; +import { persistClientSettingsUpdate, useClientSettings } from "../../hooks/useSettings"; import { readLocalApi } from "../../localApi"; +import { derivePhysicalProjectKey } from "@t3tools/client-runtime/state/project-grouping"; import { + PROJECT_GROUPING_MODE_LABELS, + withProjectGroupingOverride, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../../sidebarProjectGrouping"; @@ -31,8 +35,9 @@ import { ProjectFavicon } from "../ProjectFavicon"; import { Alert, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; -import { toastManager } from "../ui/toast"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SettingResetButton, @@ -59,6 +64,78 @@ const ProjectIconPickerDialog = lazy(() => })), ); +function isGroupingSelection(value: unknown): value is SidebarProjectGroupingMode | "inherit" { + return ( + value === "inherit" || + value === "repository" || + value === "repository_path" || + value === "separate" + ); +} + +// Grouping overrides are client settings, so they stay editable without project write access. +function CheckoutGroupingSelect({ member }: { member: SidebarProjectGroupMember }) { + const overrideKey = derivePhysicalProjectKey(member); + const globalMode = useClientSettings((settings) => settings.sidebarProjectGroupingMode); + const selection = useClientSettings( + (settings) => settings.sidebarProjectGroupingOverrides?.[overrideKey] ?? "inherit", + ); + return ( + + ); +} + export type ProjectSettingsCategory = "general" | "source-control" | "project"; interface ProjectSelection { @@ -322,19 +399,32 @@ function ProjectDetail({ if (members) members.push(member); else checkoutsByEnvironment.set(member.environmentId, [member]); } - const checkoutRemoveButton = (member: SidebarProjectGroupMember) => ( - + // A lone checkout is removed from the Danger section, but it still needs its grouping + // rule here so "Keep separate" can be undone. + const checkoutControls = (member: SidebarProjectGroupMember) => ( +
+
+ +
+ {hasMultipleCheckouts ? ( + + ) : null} +
); const checkoutChoices = ( +

+ Grouping rule decides how each checkout joins project groups in the sidebar. Changing it can + move a checkout to a different project. +

{Array.from(checkoutsByEnvironment, ([environmentId, members]) => { if (checkoutsByEnvironment.size === 1) { return members.map((member) => ( @@ -342,7 +432,7 @@ function ProjectDetail({ key={member.physicalProjectKey} title={member.environmentLabel ?? "Environment"} description={member.workspaceRoot} - control={checkoutRemoveButton(member)} + control={checkoutControls(member)} /> )); } @@ -371,26 +461,26 @@ function ProjectDetail({ ) : null} } - control={hasMultiple ? undefined : checkoutRemoveButton(firstMember)} + control={hasMultiple ? undefined : checkoutControls(firstMember)} > {hasMultiple ? (
{members.map((member) => (
+ } > {member.workspaceRoot} {member.workspaceRoot} -
{checkoutRemoveButton(member)}
+
{checkoutControls(member)}
))}
@@ -553,7 +643,7 @@ function ProjectDetail({
- {hasMultipleCheckouts ? checkoutChoices : null} + {checkoutChoices} { + const message = `${question}\nIts saved settings, including any custom binary path, will be removed.${consequence}`; + const confirmed = await settlePromise(() => + ensureLocalApi().dialogs.confirm(message, { variant: "destructive" }), + ); + return confirmed._tag === "Success" && confirmed.value; +} + function modelListChanges(current: ReadonlyArray, next: ReadonlyArray) { const currentSet = new Set(current); const nextSet = new Set(next.map((model) => model.trim()).filter(Boolean)); @@ -718,6 +739,10 @@ export function EnvironmentProviderSettings({ const deleteInstance = useStableCallback( async (instanceId: ProviderInstanceId, instance: ProviderInstanceConfig) => { + const confirmed = await confirmProviderInstanceRemoval( + `Remove the ${providerInstanceName(instance)} provider instance?`, + ); + if (!confirmed) return; const updateResult = await persistProviderInstance({ operation: "remove", instanceId, @@ -738,10 +763,17 @@ export function EnvironmentProviderSettings({ ); const resetDefaultInstance = useStableCallback(async (driverKind: ProviderDriverKind) => { - const result = await persistProviderInstance({ - operation: "remove", - instanceId: defaultInstanceIdForDriver(driverKind), - }); + const instanceId = defaultInstanceIdForDriver(driverKind); + const instance = settings.providerInstances?.[instanceId] ?? { driver: driverKind }; + const turnsOff = + resolveProviderInstanceEnabled(instance) && + !resolveProviderInstanceEnabled({ driver: driverKind }); + const confirmed = await confirmProviderInstanceRemoval( + `Reset the ${providerInstanceName(instance)} provider to its defaults?`, + turnsOff ? " It will also be turned off." : "", + ); + if (!confirmed) return; + const result = await persistProviderInstance({ operation: "remove", instanceId }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add({ diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8c8f9d341b8fa..288b86f08619c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -253,6 +253,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), + ...(settings.ghqCloneLayout !== DEFAULT_UNIFIED_SETTINGS.ghqCloneLayout + ? ["Clone into host folders"] + : []), ...(settings.confirmThreadUnpin !== DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin ? ["Unpin confirmation"] : []), @@ -366,6 +369,7 @@ export function useSettingsRestore(onRestored?: () => void) { defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + ghqCloneLayout: DEFAULT_UNIFIED_SETTINGS.ghqCloneLayout, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin, @@ -1276,6 +1280,30 @@ export function ThreadsSettingsPanel() { /> } /> + + updateSettings({ ghqCloneLayout: DEFAULT_UNIFIED_SETTINGS.ghqCloneLayout }) + } + /> + ) : null + } + control={ + updateSettings({ ghqCloneLayout: Boolean(checked) })} + aria-label="Clone into host folders" + /> + } + /> diff --git a/apps/web/src/components/settings/SettingsScopeSentence.tsx b/apps/web/src/components/settings/SettingsScopeSentence.tsx index bb6a896d45869..907cbc0b4bd84 100644 --- a/apps/web/src/components/settings/SettingsScopeSentence.tsx +++ b/apps/web/src/components/settings/SettingsScopeSentence.tsx @@ -49,7 +49,7 @@ export const SETTINGS_DEVICE_ONLY_PATHS: ReadonlySet = new Set([ "/settings/diffs", "/settings/snap-shot", "/settings/connections", - // Network access and authorized clients belong to the environment serving this app. + // Access picks its environment on the page itself. "/settings/access", "/settings/about", ]); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index cfa142d5315c6..3e09dcfe562c4 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -611,6 +611,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "environment-defaults", searchTerms: ["base directory folder browser path home"], }, + { + id: "ghq-clone-layout", + title: "Clone into host folders", + to: "/settings/threads", + scope: "environment-defaults", + searchTerms: ["ghq clone destination host owner repository root layout"], + }, { id: "unpin-confirmation", title: "Unpin confirmation", diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx index a2fc539ff58ba..41a62d8da599e 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx @@ -82,8 +82,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { return (
  • + + + ); +} + +// The tracker records on onResolved, which the router emits only once React commits the page. +async function settled(router: AnyRouter, navigation: Promise) { + await navigation; + await vi.waitFor(() => + expect(router.state.resolvedLocation?.href).toBe(router.state.location.href), + ); +} + +describe("main app return location", () => { + it("returns to the thread without re-opening the quote source it was opened at", async () => { + await using screen = await render(, { router: THREAD_PATH }); + const router = screen.router!; + using _tracking = { [Symbol.dispose]: startMainAppLocationTracking(router) }; + + const returnLocation = async (from: string, control: string) => { + await settled(router, router.navigate(assistantCitationNavigation(citation))); + expect(assistantCitationFromLocation(router.state.location.href)).toEqual(citation); + await settled(router, router.navigate({ href: from })); + await userEvent.click(screen.getByRole("button", { name: control })); + await vi.waitFor(() => expect(router.state.location.pathname).toBe(THREAD_PATH)); + return router.state.location.href; + }; + + const closingUsage = await returnLocation("/usage", "Close modal"); + const sidebarBack = await returnLocation("/pull-requests", "Back to app"); + + expect({ closingUsage, sidebarBack }).toEqual({ + closingUsage: THREAD_PATH, + sidebarBack: THREAD_PATH, + }); + }); +}); diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts index 687d41064afc9..1c29a0db16e16 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -1,5 +1,7 @@ import { useNavigate, type AnyRouter } from "@tanstack/react-router"; +import { withoutAssistantCitationHash } from "../../lib/assistantCitationNavigation"; + /** Settings and Usage open as modals over the last main-app view. */ export function isAppModalPage(pathname: string) { return pathname === "/settings" || pathname.startsWith("/settings/") || pathname === "/usage"; @@ -21,8 +23,9 @@ let appModalReturnHref: string | null = null; /** Remembers where Settings, Usage, and the utility pages lead back to. */ export function startMainAppLocationTracking(router: AnyRouter): () => void { const record = () => { - const { pathname, href } = router.state.location; + const { pathname } = router.state.location; if (isAppModalPage(pathname) || SHELL_LESS_PATHS.has(pathname)) return; + const href = withoutAssistantCitationHash(router.state.location.href); appModalReturnHref = href; if (!isSidebarUtilityPage(pathname)) mainAppHref = href; }; diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index e86217f7a8eca..6319dcfa5a052 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -110,7 +110,7 @@ function AlertDialogFooter({ function AlertDialogTitle({ className, ...props }: AlertDialogPrimitive.Title.Props) { return ( diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index a1db1108fa17a..739eb15b9a064 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -139,6 +139,7 @@ function ComboboxPopup({ sideOffset = 4, alignOffset, align = "start", + collisionAvoidance, anchor: anchorProp, ...props }: ComboboxPrimitive.Popup.Props & { @@ -147,6 +148,7 @@ function ComboboxPopup({ alignOffset?: ComboboxPrimitive.Positioner.Props["alignOffset"]; side?: ComboboxPrimitive.Positioner.Props["side"]; anchor?: ComboboxPrimitive.Positioner.Props["anchor"]; + collisionAvoidance?: ComboboxPrimitive.Positioner.Props["collisionAvoidance"]; }) { const { chipsRef, multiple } = React.use(ComboboxContext); const anchor = anchorProp ?? (multiple ? chipsRef : undefined); @@ -157,6 +159,7 @@ function ComboboxPopup({ align={align} alignOffset={alignOffset} anchor={anchor} + collisionAvoidance={collisionAvoidance} className="z-[130] select-none" data-slot="combobox-positioner" side={side} diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 656a8c0a9f2d4..2c5bc14905bbc 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -169,7 +169,7 @@ function DialogFooter({ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { return ( diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index d116065b3fdd5..3a867ab3965e3 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -142,7 +142,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { return ( diff --git a/apps/web/src/composer-markdown-source.ts b/apps/web/src/composer-markdown-source.ts new file mode 100644 index 0000000000000..11ac9fdb95bc3 --- /dev/null +++ b/apps/web/src/composer-markdown-source.ts @@ -0,0 +1,575 @@ +import { type Command, Extension, Mark as TiptapMark } from "@tiptap/core"; +import { + closeHistory, + isHistoryTransaction, + redo, + redoDepth, + undo, + undoDepth, +} from "@tiptap/pm/history"; +import type { MarkType, Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { + AllSelection, + type EditorState, + NodeSelection, + Plugin, + PluginKey, + type Selection, + TextSelection, + type Transaction, +} from "@tiptap/pm/state"; +import { type Mapping, StepMap } from "@tiptap/pm/transform"; +import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; + +import { + inlineMarkdownUnitMarks, + MARKDOWN_SOURCE_MARK, + type RichTextMark, +} from "~/composer-rich-text"; +import { caretTakesMarksBefore, serializeEditorDoc } from "~/composer-rich-text-doc"; + +// Styled text the selection touches becomes source text with the source mark, +// parsed back once the selection leaves. History steps only replay on the shape +// they were recorded in, so the history records both rewrites. Recording would +// clear redo after undo or redo, so until the next edit see `normalizeDetached`. + +const STYLE_MARKS: RichTextMark[] = ["bold", "italic", "strike", "code"]; +const PREVIEW_TAGS: Record, string> = { + bold: "strong", + italic: "em", + strike: "s", +}; +const ATOM_TEXT = ""; +const DELIMITER = /[*_~`]/; + +type TextRange = { from: number; to: number }; + +type StyledRange = TextRange & { + escapedOpener: boolean; + markers: { at: number; closing: boolean; text: string }[]; +}; + +function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { + const ranges: StyledRange[] = []; + const map = serializeEditorDoc(doc); + let range: StyledRange | null = null; + let openLength = 0; + for (const run of map.runs) { + if (run.nodeName === "codeBlock") continue; + if (run.openLen > 0) { + let backslashes = 0; + while (map.value[run.mdStart - backslashes - 1] === "\\") backslashes += 1; + range ??= { + from: run.pmPos, + to: run.pmPos, + escapedOpener: backslashes % 2 !== 0, + markers: [], + }; + range.markers.push({ + at: run.pmPos, + closing: false, + text: map.value.slice(run.mdStart, run.mdStart + run.openLen), + }); + } + if (range === null) continue; + range.to = run.pmPos + run.docLen; + if (run.closeLen > 0) { + const end = run.mdStart + run.mdLen; + range.markers.push({ + at: range.to, + closing: true, + text: map.value.slice(end - run.closeLen, end), + }); + } + openLength += run.openLen - run.closeLen; + if (openLength === 0) { + ranges.push(range); + range = null; + } + } + return ranges; +} + +/** Whether the Markdown that reveal shows for `range` parses back to the same styles. */ +function revealsFaithfully(doc: ProseMirrorNode, range: StyledRange): boolean { + if (range.escapedOpener) return false; + const adjacent = withAdjacentDelimiters(doc, range); + if (adjacent.from !== range.from || adjacent.to !== range.to) return false; + // Parsing keeps escapes as text, so styled `path\` or `2*3` stays styled, unshown. + const markers = range.markers.toSorted( + (left, right) => left.at - right.at || Number(right.closing) - Number(left.closing), + ); + const expected: Array = []; + let text = ""; + let next = 0; + const addMarkers = (pos: number) => { + for (; next < markers.length && markers[next]!.at <= pos; next++) { + text += markers[next]!.text; + expected.push(...markers[next]!.text.split("").map(() => null)); + } + }; + doc.nodesBetween(range.from, range.to, (node, pos) => { + if (!node.isInline) return true; + const marks = STYLE_MARKS.filter((name) => node.marks.some((mark) => mark.type.name === name)); + const from = Math.max(pos, range.from); + const content = node.isText ? node.text!.slice(from - pos, range.to - pos) : ATOM_TEXT; + for (let index = 0; index < content.length; index++) { + addMarkers(from + index); + text += content[index]; + expected.push(marks); + } + return false; + }); + addMarkers(range.to); + return inlineMarkdownUnitMarks(text).every((marks, index) => sameMarks(marks, expected[index]!)); +} + +function touches(selection: Selection, { from, to }: TextRange): boolean { + return selection.empty + ? selection.from >= from && selection.from <= to + : selection.from < to && selection.to > from; +} + +function sourceRegions(doc: ProseMirrorNode, type: MarkType): TextRange[] { + const regions: TextRange[] = []; + doc.descendants((node, pos) => { + if (!node.isTextblock) return true; + let current: TextRange | null = null; + node.forEach((child, offset) => { + const start = pos + 1 + offset; + if (!type.isInSet(child.marks)) { + current = null; + } else if (current) { + current.to = start + child.nodeSize; + } else { + current = { from: start, to: start + child.nodeSize }; + regions.push(current); + } + }); + return false; + }); + return regions; +} + +/** Takes in delimiters typed beside the source, like a closing `**` retyped after its text. */ +function withAdjacentDelimiters(doc: ProseMirrorNode, region: TextRange): TextRange { + const before = doc.resolve(region.from).nodeBefore; + const after = doc.resolve(region.to).nodeAfter; + let from = region.from; + let to = region.to; + if (before?.isText && before.marks.length === 0) { + const text = before.text!; + let count = 0; + while (count < text.length && DELIMITER.test(text[text.length - 1 - count]!)) count += 1; + // Only an odd backslash run escapes the first adjacent delimiter. + let backslashes = 0; + while (text[text.length - 1 - count - backslashes] === "\\") backslashes += 1; + if (count > 0 && backslashes % 2 === 1) count -= 1; + from -= count; + } + if (after?.isText && after.marks.length === 0) { + const text = after.text!; + let count = 0; + while (count < text.length && DELIMITER.test(text[count]!)) count += 1; + to += count; + } + return { from, to }; +} + +function sameMarks(left: RichTextMark[] | null, right: RichTextMark[] | null): boolean { + if (left === null || right === null) return left === right; + return left.length === right.length && left.every((mark) => right.includes(mark)); +} + +/** Calls `visit` for each run of units that parse the same way. */ +function forEachUnitRun( + units: ReadonlyArray, + visit: (start: number, end: number, marks: RichTextMark[] | null) => void, +): void { + let start = 0; + while (start < units.length) { + let end = start + 1; + while (end < units.length && sameMarks(units[end]!, units[start]!)) end += 1; + visit(start, end, units[start]!); + start = end; + } +} + +function regionUnits(doc: ProseMirrorNode, { from, to }: TextRange) { + const text = doc.textBetween(from, to, undefined, ATOM_TEXT); + return { text, units: inlineMarkdownUnitMarks(text) }; +} + +function collapseRegion(tr: Transaction, region: TextRange, type: MarkType): void { + const { schema } = tr.doc.type; + const { units } = regionUnits(tr.doc, region); + tr.removeMark(region.from, region.to, type); + forEachUnitRun(units, (start, end, marks) => { + for (const name of marks ?? []) { + const markType = schema.marks[name]; + if (markType) tr.addMark(region.from + start, region.from + end, markType.create()); + } + }); + const deletions: TextRange[] = []; + forEachUnitRun(units, (start, end, marks) => { + if (marks === null) deletions.push({ from: region.from + start, to: region.from + end }); + }); + for (const { from, to } of deletions.toReversed()) tr.delete(from, to); +} + +/** Parses source the selection does not touch back into marks, in place. */ +function collapseMarkdownSource( + tr: Transaction, + keep: (region: TextRange) => boolean = () => false, +): void { + const type = tr.doc.type.schema.marks[MARKDOWN_SOURCE_MARK]; + if (!type) return; + const regions: TextRange[] = []; + for (const region of sourceRegions(tr.doc, type)) { + const extended = withAdjacentDelimiters(tr.doc, region); + const previous = regions.at(-1); + if (previous && extended.from <= previous.to) previous.to = Math.max(previous.to, extended.to); + else regions.push(extended); + } + for (const region of regions.toReversed()) { + if (keep(region)) tr.addMark(region.from, region.to, type.create()); + else collapseRegion(tr, region, type); + } +} + +function mapSelection(tr: Transaction, selection: Selection, map: (pos: number) => number) { + if (selection instanceof AllSelection) return new AllSelection(tr.doc); + if (selection instanceof NodeSelection) { + const pos = map(selection.from); + if (tr.doc.nodeAt(pos)) return NodeSelection.create(tr.doc, pos); + } + return TextSelection.create(tr.doc, map(selection.anchor), map(selection.head)); +} + +/** Parses all shown source first, so a command that splits it keeps its styles. */ +export const collapseShownMarkdownSource: Command = ({ tr }) => { + collapseMarkdownSource(tr); + return true; +}; + +function mapRange(mapping: Mapping, { from, to }: TextRange): TextRange { + return { from: mapping.map(from, 1), to: mapping.map(to, -1) }; +} + +/** Shows styled text the selection touches as Markdown, returning the source it added. */ +function revealStyledText(tr: Transaction, type: MarkType, typed: boolean, takesBefore: boolean) { + const { schema } = tr.doc.type; + const selection = tr.selection; + const ranges = collectStyledRanges(tr.doc).filter( + (range) => touches(selection, range) && revealsFaithfully(tr.doc, range), + ); + const revealed: { range: TextRange; step: number }[] = []; + const insertions: { at: number; length: number; closing: boolean }[] = []; + for (const range of ranges.toReversed()) { + for (const name of STYLE_MARKS) { + const markType = schema.marks[name]; + if (markType) tr.removeMark(range.from, range.to, markType); + } + let length = 0; + // Inserting from the end keeps earlier positions valid; at a shared + // position the opening marker goes in first so the closing one ends up before it. + const markers = range.markers.toSorted( + (left, right) => right.at - left.at || Number(left.closing) - Number(right.closing), + ); + for (const { at, closing, text } of markers) { + tr.insert(at, schema.text(text, [type.create()])); + insertions.push({ at, length: text.length, closing }); + length += text.length; + } + tr.addMark(range.from, range.to + length, type.create()); + revealed.push({ range: { from: range.from, to: range.to + length }, step: tr.steps.length }); + } + if (ranges.length === 0) return []; + // A caret that arrives at a styled edge stays outside its markers. One that + // just typed styled text stays inside them, so the next character continues it. + const map = (pos: number) => { + let next = pos; + for (const { at, length, closing } of insertions) { + const inside = typed && selection.empty && (closing ? takesBefore : !takesBefore); + if (at < pos || (at === pos && (selection instanceof NodeSelection || closing !== inside))) { + next += length; + } + } + return next; + }; + tr.setSelection(mapSelection(tr, selection, map)); + return revealed.map(({ range, step }) => mapRange(tr.mapping.slice(step), range)); +} + +/** Shows styled text the selection touches as Markdown, and parses source it left. */ +function normalizeMarkdownSource(state: EditorState, typed: boolean): Transaction | null { + const type = state.schema.marks[MARKDOWN_SOURCE_MARK]; + if (!type) return null; + const tr = state.tr; + collapseMarkdownSource(tr, (region) => touches(state.selection, region)); + revealStyledText(tr, type, typed, caretTakesMarksBefore(state)); + return tr.docChanged ? tr : null; +} + +/** + * After undo or redo, history items may point inside shown source, and a + * rewrite in the history would clear redo. So until the next recorded edit, + * rewrites stay out of it and only remove text they added: source the history + * knows is hidden instead of parsed, and source revealed here is parsed back. + */ +function normalizeDetached(state: EditorState, revealed: TextRange[]): Transaction | null { + const type = state.schema.marks[MARKDOWN_SOURCE_MARK]; + if (!type) return null; + const tr = state.tr; + const kept: TextRange[] = []; + for (const region of revealed.toSorted((left, right) => right.from - left.from)) { + if (touches(state.selection, region)) kept.push(region); + else collapseRegion(tr, region, type); + } + const added = revealStyledText(tr, type, false, caretTakesMarksBefore(state)); + if (!tr.docChanged) return null; + const shown = [...kept.map((region) => mapRange(tr.mapping, region)), ...added]; + return tr.setMeta(markdownSourceKey, shown).setMeta("addToHistory", false); +} + +/** Parses the source `normalizeDetached` revealed, leaving the document the history knows. */ +function collapseRevealed(state: EditorState, revealed: TextRange[]): Transaction { + const tr = state.tr; + const type = state.schema.marks[MARKDOWN_SOURCE_MARK]; + if (type) { + for (const region of revealed.toSorted((left, right) => right.from - left.from)) { + collapseRegion(tr, region, type); + } + } + return tr.setMeta(markdownSourceKey, []).setMeta("addToHistory", false); +} + +/** Replays `edit` after undoing `collapse`, so the history records the reveal with the edit. */ +function rebaseEdit(tr: Transaction, collapse: Transaction, edit: Transaction): Transaction { + for (let index = collapse.steps.length - 1; index >= 0; index--) { + tr.step(collapse.steps[index]!.invert(collapse.docs[index]!)); + } + for (const step of edit.steps) tr.step(step); + tr.setSelection(edit.selection.map(tr.doc, StepMap.empty)).setTime(edit.time); + if (edit.storedMarksSet) tr.setStoredMarks(edit.storedMarks); + if (edit.scrolledIntoView) tr.scrollIntoView(); + // Metadata has no public accessor, and grouping, paste rules and composition read it. + type WithMeta = { meta: Record }; + Object.assign((tr as unknown as WithMeta).meta, (edit as unknown as WithMeta).meta); + return tr; +} + +const replayKey = new PluginKey("composer-markdown-source-replay"); + +/** + * Replays the edit a request carries after the source plugin appended its collapse, + * in the request's one view update so an IME keeps its preedit. History ignores what + * is appended to an unrecorded transaction, so the request itself records nothing. + */ +const replayPlugin = new Plugin({ + key: replayKey, + appendTransaction([request, collapse], _, state) { + const edit = request?.getMeta(replayKey) as Transaction | undefined; + // Appended transactions otherwise join the previous undo event. + return edit && collapse ? closeHistory(rebaseEdit(state.tr, collapse, edit)) : null; + }, +}); + +function previewDecorations( + doc: ProseMirrorNode, + type: MarkType, + hidden: Selection | null, +): DecorationSet { + const decorations: Decoration[] = []; + for (const region of sourceRegions(doc, type)) { + const hide = hidden !== null && !touches(hidden, region); + const { text, units } = regionUnits(doc, region); + const codeTicks = new Set(); + forEachUnitRun(units, (start, end, marks) => { + for (const name of marks ?? []) { + if (name !== "code") { + decorations.push( + Decoration.inline(region.from + start, region.from + end, { + nodeName: PREVIEW_TAGS[name], + }), + ); + continue; + } + // One element holds the ticks and the code, so a right-to-left line + // keeps both ticks on their own sides of it. + let from = start; + let to = end; + if (units[from - 1] === null && text[from - 1] === "`") codeTicks.add((from -= 1)); + if (units[to] === null && text[to] === "`") codeTicks.add(to++); + decorations.push( + Decoration.inline(region.from + from, region.from + to, { nodeName: "code", dir: "ltr" }), + ); + } + }); + forEachUnitRun( + units.map((marks, index) => (marks === null && (hide || !codeTicks.has(index)) ? null : [])), + (start, end, marks) => { + if (marks !== null) return; + decorations.push( + Decoration.inline(region.from + start, region.from + end, { + class: hide ? "composer-rich-marker-hidden" : "composer-rich-marker", + }), + ); + }, + ); + } + return DecorationSet.create(doc, decorations); +} + +type MarkdownSourceState = { + decorations: DecorationSet; + /** From an undo or redo until the next recorded edit; see `normalizeDetached`. */ + detached: boolean; + /** Source `normalizeDetached` revealed, which the history knows as styled text. */ + revealed: TextRange[]; +}; + +const markdownSourceKey = new PluginKey("composer-markdown-source"); + +function markdownSourcePlugin(type: MarkType): Plugin { + let view: EditorView | null = null; + return new Plugin({ + key: markdownSourceKey, + view(editorView) { + view = editorView; + return { + destroy() { + view = null; + }, + }; + }, + state: { + init: (_, state) => ({ + decorations: previewDecorations(state.doc, type, null), + detached: false, + revealed: [], + }), + apply: (tr, old, _, state) => { + const history = isHistoryTransaction(tr); + const detached = + history || (old.detached && !(tr.docChanged && tr.getMeta("addToHistory") !== false)); + const own = tr.getMeta(markdownSourceKey) as TextRange[] | undefined; + const revealed = + !detached || history + ? [] + : (own ?? + (tr.docChanged + ? old.revealed.map((range) => mapRange(tr.mapping, range)) + : old.revealed)); + const redraw = tr.docChanged || detached !== old.detached || (detached && tr.selectionSet); + return { + decorations: redraw + ? previewDecorations(state.doc, type, detached ? state.selection : null) + : old.decorations, + detached, + revealed, + }; + }, + }, + props: { + decorations: (state) => markdownSourceKey.getState(state)?.decorations, + }, + appendTransaction(transactions, _, state) { + if (transactions[0]?.getMeta(replayKey)) { + return collapseRevealed(state, markdownSourceKey.getState(state)?.revealed ?? []); + } + // Rewriting text mid-composition would break the IME's preedit. + if (view?.composing) return null; + // Rewrites appended to an undo or redo join its history step, which leaves + // the other direction unmapped. The dispatch hook normalizes after it instead. + if (transactions.some((tr) => isHistoryTransaction(tr) || tr.getMeta(markdownSourceKey))) { + return null; + } + if (!transactions.some((tr) => tr.docChanged || tr.selectionSet)) return null; + const source = markdownSourceKey.getState(state); + if (source?.detached) return normalizeDetached(state, source.revealed); + const tr = normalizeMarkdownSource( + state, + transactions.some((item) => item.docChanged), + ); + // With nothing to undo, a recorded rewrite would only add a no-op undo. + if (tr && undoDepth(state) === 0) tr.setMeta("addToHistory", false); + return tr; + }, + }); +} + +/** Keeps source `normalizeDetached` revealed out of what undo, redo and edits see. */ +const MarkdownSourceHistoryExtension = Extension.create({ + name: "composer-markdown-source-history", + // Runs after undo grouping, so a rebased edit keeps the grouping chosen for it. + priority: 1, + dispatchTransaction({ transaction, next }) { + const { editor } = this; + const revealed = markdownSourceKey.getState(editor.state)?.revealed ?? []; + if (isHistoryTransaction(transaction)) { + if (revealed.length === 0) { + next(transaction); + } else { + const undoing = redoDepth(editor.state.apply(transaction)) > redoDepth(editor.state); + next(collapseRevealed(editor.state, revealed)); + (undoing ? undo : redo)(editor.state, next); + } + const source = markdownSourceKey.getState(editor.state); + const tr = editor.view.composing + ? null + : normalizeDetached(editor.state, source?.revealed ?? []); + if (tr) next(tr); + return; + } + const recordedEdit = + transaction.docChanged && + transaction.getMeta("addToHistory") !== false && + transaction.getMeta(markdownSourceKey) === undefined; + if (revealed.length === 0 || !recordedEdit) { + next(transaction); + return; + } + next(editor.state.tr.setMeta(replayKey, transaction)); + }, +}); + +export const ComposerMarkdownSourceExtension = TiptapMark.create({ + name: MARKDOWN_SOURCE_MARK, + // Its shortcuts run ahead of the style marks' own. + priority: 1001, + inclusive: false, + parseHTML() { + return [{ tag: "span.composer-markdown-source" }]; + }, + renderHTML() { + return ["span", { class: "composer-markdown-source" }, 0]; + }, + addKeyboardShortcuts() { + // Shown markers are plain text, so toggling a style there would wrap them + // in another style. Parse the source first and toggle the result. + const toggle = (name: RichTextMark) => () => { + const { state } = this.editor; + if (state.selection.empty) return false; + const touched = sourceRegions(state.doc, this.type).some((region) => + touches(state.selection, region), + ); + if (!touched) return false; + return this.editor.chain().command(collapseShownMarkdownSource).toggleMark(name).run(); + }; + return { + "Mod-b": toggle("bold"), + "Mod-B": toggle("bold"), + "Mod-i": toggle("italic"), + "Mod-I": toggle("italic"), + "Mod-Shift-s": toggle("strike"), + "Mod-e": toggle("code"), + }; + }, + addExtensions() { + return [MarkdownSourceHistoryExtension]; + }, + addProseMirrorPlugins() { + return [markdownSourcePlugin(this.type), replayPlugin]; + }, +}); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index ae0c9441625b4..95975bf285745 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -42,7 +42,12 @@ import { ReplaceStep } from "@tiptap/pm/transform"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; import { nextOrderedMarkerText } from "~/composer-list-continuation"; -import { parseInlineMarkdown, RICH_TEXT_DELIMITERS, type RichTextMark } from "~/composer-rich-text"; +import { + MARKDOWN_SOURCE_MARK, + parseInlineMarkdown, + RICH_TEXT_DELIMITERS, + type RichTextMark, +} from "~/composer-rich-text"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; import { composerMathRanges } from "@t3tools/shared/composerInlineTokens"; @@ -90,6 +95,7 @@ function codePairCaretPlugin(type: MarkType) { if (view.composing || from !== to || text !== "`") return false; const $from = view.state.doc.resolve(from); if (!$from.parent.isTextblock || $from.parent.type.spec.code) return false; + if (touchesMarkdownSource(view.state, from, to)) return false; const before = $from.nodeBefore?.text ?? ""; if (closesBacktickPair($from)) { view.dispatch(insert().setMeta(codePairKey, { from: from - 1, to: from + 1 })); @@ -218,7 +224,10 @@ export const ComposerCodeExtension = Code.extend({ find: rule.find, undoable: rule.undoable, handler: (props) => { - if (literalBackticksBefore(props.state.doc.resolve(props.range.from)) % 2 === 1) { + if ( + literalBackticksBefore(props.state.doc.resolve(props.range.from)) % 2 === 1 || + touchesMarkdownSource(props.state, props.range.from, props.range.to) + ) { return null; } @@ -246,6 +255,18 @@ function literalBackticksBefore($pos: ResolvedPos): number { return count; } +/** + * Whether `from`-`to`, or the caret at `from`, is in shown Markdown source. + * Typed delimiters there stay text until the caret leaves and the source is parsed. + */ +function touchesMarkdownSource(state: EditorState, from: number, to: number): boolean { + const type = state.schema.marks[MARKDOWN_SOURCE_MARK]; + if (!type) return false; + if (from < to) return state.doc.rangeHasMark(from, to, type); + const $pos = state.doc.resolve(from); + return [$pos.nodeBefore, $pos.nodeAfter].some((node) => node && type.isInSet(node.marks)); +} + // Code permits nested emphasis, so Tiptap's exclusion check cannot protect literal delimiters. function markInputRuleOutsideCode(find: RegExp, type: MarkType) { const rule = markInputRule({ find, type }); @@ -257,6 +278,7 @@ function markInputRuleOutsideCode(find: RegExp, type: MarkType) { const content = match[match.length - 1] ?? ""; const textStart = range.from + match[0].indexOf(content); const textEnd = textStart + content.length; + if (touchesMarkdownSource(state, range.from, range.to)) return null; if ( code && (state.doc.rangeHasMark(range.from + match[0].search(/\S/), textStart, code) || @@ -1206,7 +1228,8 @@ function renumberFollowingItems( }); } -export function splitOrLiftListItem(editor: Editor): boolean { +/** `prepare` runs first in the same transaction, which the split then reads. */ +export function splitOrLiftListItem(editor: Editor, prepare: Command): boolean { const $from = editor.state.selection.$from; const itemType = listItemTypeAt($from); if (!itemType) return false; @@ -1221,7 +1244,7 @@ export function splitOrLiftListItem(editor: Editor): boolean { if (moved) alignListItemWithSiblings(tr, itemType); return moved; }; - return editor.can().command(move) && editor.chain().command(move).run(); + return editor.can().command(move) && editor.chain().command(prepare).command(move).run(); } export function convertBulletItemToTask( @@ -1639,15 +1662,20 @@ export function pmToFlat(map: RichDocMap, pmPos: number): number { // Styled boundaries need two caret stops at one position, one for each side's marks. +// Shown Markdown source has real characters at its edges, so it needs no extra stop. +function styleMarks(marks: readonly Mark[]): readonly Mark[] { + return marks.filter((mark) => mark.type.name !== MARKDOWN_SOURCE_MARK); +} + function styledEdge(state: EditorState) { const { selection } = state; if (!selection.empty) return null; const { $from } = selection; if (!$from.parent.inlineContent) return null; - const before = $from.nodeBefore?.marks ?? Mark.none; - const after = $from.nodeAfter?.marks ?? Mark.none; + const before = styleMarks($from.nodeBefore?.marks ?? Mark.none); + const after = styleMarks($from.nodeAfter?.marks ?? Mark.none); if (Mark.sameSet(before, after)) return null; - return { before, after, current: state.storedMarks ?? $from.marks() }; + return { before, after, current: styleMarks(state.storedMarks ?? $from.marks()) }; } export function caretTakesMarksBefore(state: EditorState): boolean { diff --git a/apps/web/src/composer-rich-text.test.ts b/apps/web/src/composer-rich-text.test.ts index 3fca4b7196c91..f967cfd3a30fa 100644 --- a/apps/web/src/composer-rich-text.test.ts +++ b/apps/web/src/composer-rich-text.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { parseInlineMarkdown } from "./composer-rich-text"; +import { inlineMarkdownUnitMarks, parseInlineMarkdown } from "./composer-rich-text"; describe("composer rich text markdown", () => { it("preserves unmatched markers, escaped markers, and identifiers", () => { @@ -56,4 +56,22 @@ describe("composer rich text markdown", () => { { text: "**not bold**", marks: ["code"] }, ]); }); + + it("maps each source character to its marks, or null for a dropped delimiter", () => { + const bold = ["bold"]; + expect(inlineMarkdownUnitMarks("**`*` w** \\*")).toEqual([ + null, + null, + null, + ["bold", "code"], + null, + bold, + bold, + null, + null, + [], + [], + [], + ]); + }); }); diff --git a/apps/web/src/composer-rich-text.ts b/apps/web/src/composer-rich-text.ts index 2a08f91636576..49cca9f5ec93a 100644 --- a/apps/web/src/composer-rich-text.ts +++ b/apps/web/src/composer-rich-text.ts @@ -12,29 +12,35 @@ export const RICH_TEXT_DELIMITERS: Record = { code: "`", }; -function pushSpan(spans: RichTextSpan[], text: string, marks: RichTextMark[]): void { - if (!text) return; - const last = spans[spans.length - 1]; - if ( - last && - last.marks.length === marks.length && - last.marks.every((mark, index) => mark === marks[index]) - ) { - last.text += text; - } else { - spans.push({ text, marks }); - } +/** A run of `text` from `start` to `end` that parses with `marks`. */ +interface SourcePiece { + start: number; + end: number; + marks: RichTextMark[]; } -export function parseInlineMarkdown(text: string): RichTextSpan[] { - const root: RichTextSpan[] = []; - const stack: { delimiter: string; mark: RichTextMark; spans: RichTextSpan[] }[] = []; - const current = () => stack.at(-1)?.spans ?? root; +function sameMarks(left: readonly RichTextMark[], right: readonly RichTextMark[]): boolean { + return left.length === right.length && left.every((mark, index) => mark === right[index]); +} + +// Pieces keep source offsets, so callers can map parsed text back onto the +// characters it came from; parsing only drops delimiters. +function parseInlineMarkdownPieces(text: string): SourcePiece[] { + const root: SourcePiece[] = []; + const stack: { delimiter: string; mark: RichTextMark; start: number; pieces: SourcePiece[] }[] = + []; + const push = (start: number, end: number, marks: RichTextMark[] = []) => { + if (end <= start) return; + const pieces = stack.at(-1)?.pieces ?? root; + const last = pieces.at(-1); + if (last?.end === start && sameMarks(last.marks, marks)) last.end = end; + else pieces.push({ start, end, marks }); + }; let index = 0; while (index < text.length) { const char = text[index]!; if (char === "\\") { - pushSpan(current(), text.slice(index, index + 2), []); + push(index, Math.min(index + 2, text.length)); index += 2; continue; } @@ -47,16 +53,16 @@ export function parseInlineMarkdown(text: string): RichTextSpan[] { !text.slice(index, close).includes("\n") && text[close + 1] !== "`" ) { - pushSpan(current(), text.slice(index + 1, close), ["code"]); + push(index + 1, close, ["code"]); index = close + 1; } else { - pushSpan(current(), run, []); + push(index, index + run.length); index += run.length; } continue; } if (char !== "*" && char !== "_" && char !== "~") { - pushSpan(current(), char, []); + push(index, index + 1); index += 1; continue; } @@ -82,33 +88,54 @@ export function parseInlineMarkdown(text: string): RichTextSpan[] { index + top.delimiter.length <= end ) { stack.pop(); - for (const span of top.spans) pushSpan(current(), span.text, [top.mark, ...span.marks]); + for (const piece of top.pieces) push(piece.start, piece.end, [top.mark, ...piece.marks]); index += top.delimiter.length; } else if (canOpen && (char !== "~" || end - index >= 2)) { const length = char === "~" || end - index >= 2 ? 2 : 1; const mark = char === "~" ? "strike" : length === 2 ? "bold" : "italic"; // Prevent self-nesting, including alternate delimiters, to bound the stack by supported styles. if (stack.some((frame) => frame.mark === mark)) { - pushSpan(current(), text.slice(index, end), []); + push(index, end); index = end; continue; } - stack.push({ - delimiter: char.repeat(length), - mark, - spans: [], - }); + stack.push({ delimiter: char.repeat(length), mark, start: index, pieces: [] }); index += length; } else { - pushSpan(current(), text.slice(index, end), []); + push(index, end); index = end; } } } while (stack.length > 0) { const frame = stack.pop()!; - pushSpan(current(), frame.delimiter, []); - for (const span of frame.spans) pushSpan(current(), span.text, span.marks); + push(frame.start, frame.start + frame.delimiter.length); + for (const piece of frame.pieces) push(piece.start, piece.end, piece.marks); } return root; } + +export function parseInlineMarkdown(text: string): RichTextSpan[] { + const spans: RichTextSpan[] = []; + for (const { start, end, marks } of parseInlineMarkdownPieces(text)) { + const last = spans.at(-1); + if (last && sameMarks(last.marks, marks)) { + last.text += text.slice(start, end); + } else { + spans.push({ text: text.slice(start, end), marks }); + } + } + return spans; +} + +/** Marks text the composer shows as editable Markdown source while the caret is in it. */ +export const MARKDOWN_SOURCE_MARK = "markdownSource"; + +/** Marks for each UTF-16 unit of `text`, or null for a delimiter that parsing drops. */ +export function inlineMarkdownUnitMarks(text: string): Array { + const result: Array = Array.from({ length: text.length }, () => null); + for (const { start, end, marks } of parseInlineMarkdownPieces(text)) { + result.fill(marks, start, end); + } + return result; +} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index a4550b62cda49..fd0b144f23ae3 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1685,6 +1685,7 @@ describe("composerDraftStore modelSelection", () => { ).toEqual( expect.objectContaining({ instanceId: CODEX_SECONDARY_INSTANCE, + model: "gpt-5-codex", options: [{ id: "reasoningEffort", value: "low" }], }), ); @@ -1701,6 +1702,19 @@ describe("composerDraftStore modelSelection", () => { options: [{ id: "reasoningEffort", value: "low" }], }), ); + expect( + useComposerDraftStore.getState().stickyOptionsByModelByProvider[CODEX_SECONDARY_INSTANCE], + ).toEqual({ "gpt-5-codex": [{ id: "reasoningEffort", value: "low" }] }); + + // A custom slug that matches a Codex alias stays exact; the picker already resolved it. + store.setModelSelection(threadRef, createModelSelection(CODEX_SECONDARY_INSTANCE, "gpt-5.3"), { + explicit: true, + replaceOptions: true, + }); + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_SECONDARY_INSTANCE] + ?.model, + ).toBe("gpt-5.3"); }); }); @@ -2091,6 +2105,52 @@ describe("composerDraftStore model seed migration", () => { vi.useRealTimers(); } }); + + it("upgrades v5 selections that name a provider and keep options in an object", async () => { + vi.useFakeTimers(); + try { + const legacySelection = { + provider: "codex", + model: "gpt-5.4", + options: { reasoningEffort: "high", fastMode: true }, + }; + const storage = useComposerDraftStore.persist.getOptions().storage; + expect(storage).toBeDefined(); + storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, { + version: 5, + state: { + draftsByThreadKey: { + [serverThreadKey]: { + prompt: "keep this draft", + attachments: [], + modelSelectionByProvider: { codex: legacySelection }, + activeProvider: "codex", + }, + }, + draftThreadsByThreadKey: {}, + stickyModelSelectionByProvider: { codex: legacySelection }, + stickyActiveProvider: "codex", + }, + } as never); + await vi.advanceTimersByTimeAsync(300); + + await useComposerDraftStore.persist.rehydrate(); + + const upgraded = modelSelection(CODEX_DRIVER, "gpt-5.4", { + reasoningEffort: "high", + fastMode: true, + }); + expect(draftByKey(serverThreadKey)).toMatchObject({ + prompt: "keep this draft", + modelSelectionByProvider: { [CODEX_INSTANCE]: upgraded }, + }); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider).toEqual({ + [CODEX_INSTANCE]: upgraded, + }); + } finally { + vi.useRealTimers(); + } + }); }); function createMockStorage() { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d506e2a7a53e2..5156d0c73a437 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -37,6 +37,7 @@ import { DeepMutable } from "effect/Types"; import { createModelSelection, draftModelPickMatchesThread, + normalizeCustomModelSlug, normalizeModelSlug, } from "@t3tools/shared/model"; import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; @@ -962,7 +963,11 @@ function normalizeModelSelection( const driverKindHint = normalizeProviderDriverKind(candidate?.provider ?? legacy?.provider) ?? ProviderDriverKind.make("codex"); - const model = normalizeModelSlug(rawModel, driverKindHint); + // Instance-scoped picks are already resolved slugs; a custom slug can match a static alias. + const model = + candidate?.instanceId === undefined + ? normalizeModelSlug(rawModel, driverKindHint) + : normalizeCustomModelSlug(rawModel); if (!model) { return null; } @@ -1748,6 +1753,22 @@ function normalizePersistedDraftThreads( return { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey }; } +const decodePersistedModelSelection = Schema.decodeUnknownOption(ModelSelection); + +/** Storage v3-v5 wrote `provider` and object-shaped options; the schema upgrades both. */ +function normalizePersistedModelSelectionByProvider( + value: object, +): Partial> { + return Object.fromEntries( + Object.entries(value).flatMap(([instanceId, selection]) => + Option.match(decodePersistedModelSelection(selection), { + onNone: () => [], + onSome: (decoded) => [[instanceId, decoded] as const], + }), + ), + ); +} + function normalizePersistedDraftsByThreadId( rawDraftMap: unknown, draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"], @@ -1849,9 +1870,9 @@ function normalizePersistedDraftsByThreadId( draftCandidate.modelSelectionByProvider && typeof draftCandidate.modelSelectionByProvider === "object" ) { - modelSelectionByProvider = draftCandidate.modelSelectionByProvider as Partial< - Record - >; + modelSelectionByProvider = normalizePersistedModelSelectionByProvider( + draftCandidate.modelSelectionByProvider, + ); activeProvider = normalizeProviderInstanceId(draftCandidate.activeProvider); modelSelectionExplicit = draftCandidate.modelSelectionExplicit === true ? true : undefined; } else { @@ -2135,10 +2156,9 @@ function normalizeCurrentPersistedComposerDraftStoreState( normalizedPersistedState.stickyModelSelectionByProvider && typeof normalizedPersistedState.stickyModelSelectionByProvider === "object" ) { - stickyModelSelectionByProvider = - normalizedPersistedState.stickyModelSelectionByProvider as Partial< - Record - >; + stickyModelSelectionByProvider = normalizePersistedModelSelectionByProvider( + normalizedPersistedState.stickyModelSelectionByProvider, + ); stickyActiveProvider = normalizeProviderInstanceId( normalizedPersistedState.stickyActiveProvider, ); @@ -2971,7 +2991,7 @@ const composerDraftStore = create()( } const instanceKey = options?.instanceId ?? defaultInstanceIdForDriver(normalizedProvider); const fallbackModel = - normalizeModelSlug(options?.model, normalizedProvider) ?? + normalizeCustomModelSlug(options?.model) ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider] ?? DEFAULT_MODEL; const providerOpts = @@ -3003,8 +3023,7 @@ const composerDraftStore = create()( nextStickyMap[instanceKey] ?? base.modelSelectionByProvider[instanceKey] ?? createModelSelection(instanceKey, fallbackModel); - const rememberedModel = - normalizeModelSlug(options?.model, normalizedProvider) ?? stickyBase.model; + const rememberedModel = normalizeCustomModelSlug(options?.model) ?? stickyBase.model; if (providerOpts) { nextStickyMap[instanceKey] = createModelSelection( instanceKey, diff --git a/apps/web/src/diffFileActions.test.ts b/apps/web/src/diffFileActions.test.ts index b4fdb71f8d89a..f7faf72ef8bd3 100644 --- a/apps/web/src/diffFileActions.test.ts +++ b/apps/web/src/diffFileActions.test.ts @@ -69,23 +69,45 @@ describe("openDiffFilePrimaryAction", () => { ).toBe("index.ts"); }); - it.each(["backend/server.ts", "frontend2/app.ts", "frontend/../secret.ts", "C:secret.ts"])( - "does not open an out-of-project diff path: %s", - (filePath) => { - const openInEditor = vi.fn(); - - openDiffFilePrimaryAction({ - threadRef: THREAD_REF, - filePath, - activeCwd: "/repo/frontend", - repositoryRoot: "/repo", - openInEditor, - }); - + it.each([ + { + filePath: "/repo/frontend/src/index.ts", + workspaceRoot: "/repo/frontend", + expected: "src/index.ts", + }, + { filePath: "c:\\Repo\\src\\index.ts", workspaceRoot: "C:\\repo", expected: "src/index.ts" }, + ])( + "opens absolute diff paths inside the workspace: $filePath", + ({ filePath, workspaceRoot, expected }) => { expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), - ).toMatchObject({ isOpen: false }); - expect(openInEditor).not.toHaveBeenCalled(); + resolveDiffPathForWorkspace({ filePath, workspaceRoot, repositoryRoot: workspaceRoot }), + ).toBe(expected); }, ); + + it.each([ + "backend/server.ts", + "frontend2/app.ts", + "frontend/../secret.ts", + "C:secret.ts", + "/repo/backend/server.ts", + "/repo/frontend/../backend/server.ts", + "/repo/frontend", + "/etc/passwd", + ])("does not open an out-of-project diff path: %s", (filePath) => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: THREAD_REF, + filePath, + activeCwd: "/repo/frontend", + repositoryRoot: "/repo", + openInEditor, + }); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toMatchObject({ isOpen: false }); + expect(openInEditor).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/diffFileActions.ts b/apps/web/src/diffFileActions.ts index 99baf13c94160..c6a3fa3d436fc 100644 --- a/apps/web/src/diffFileActions.ts +++ b/apps/web/src/diffFileActions.ts @@ -1,5 +1,10 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; -import { isWindowsAbsolutePath, normalizeProjectPathForComparison } from "@t3tools/shared/path"; +import { + isAbsolutePath, + isWindowsAbsolutePath, + normalizeProjectPathForComparison, + workspaceRelativeFilePath, +} from "@t3tools/shared/path"; import { useRightPanelStore } from "./rightPanelStore"; import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; @@ -52,6 +57,13 @@ export function resolveDiffPathForWorkspace(input: { readonly workspaceRoot: string | undefined; readonly repositoryRoot: string | undefined; }): string | null { + // Claude and ACP tool-call diffs name files by absolute path. + if (isAbsolutePath(input.filePath)) { + const relativePath = workspaceRelativeFilePath(input.filePath, input.workspaceRoot); + const segments = relativePath === null ? null : normalizedRelativePathSegments(relativePath); + return segments ? segments.join("/") : null; + } + const fileSegments = normalizedRelativePathSegments(input.filePath); if (!fileSegments) return null; diff --git a/apps/web/src/hooks/useFileMetadata.ts b/apps/web/src/hooks/useFileMetadata.ts index 497cc67d989eb..398df04a88ded 100644 --- a/apps/web/src/hooks/useFileMetadata.ts +++ b/apps/web/src/hooks/useFileMetadata.ts @@ -1,4 +1,8 @@ -import { AuthFilesystemReadScope, type EnvironmentId } from "@t3tools/contracts"; +import { + AuthFilesystemReadScope, + type EnvironmentId, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { resolvePathLinkTarget, splitFilePathPosition } from "@t3tools/shared/fileLinks"; import { isAbsolutePath, @@ -12,22 +16,21 @@ import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { FileMetadataThreadContext } from "./FileMetadataThreadProvider"; -export function useFileMetadata( - environmentId: EnvironmentId | null | undefined, +export function getFileMetadataQueryAtom( + environmentId: EnvironmentId, path: string, cwd?: string, + threadRef?: ScopedThreadRef | null, ) { - const canRead = useEnvironmentScope(environmentId ?? null, AuthFilesystemReadScope); - const threadRef = useContext(FileMetadataThreadContext); const hostPath = splitFilePathPosition(path).path; const targetPath = normalizeProjectPathForComparison( hostPath.startsWith("~/") || !cwd ? hostPath : resolvePathLinkTarget(hostPath, cwd), ); const absolute = isAbsolutePath(targetPath) || targetPath.startsWith("~/"); - return useEnvironmentQuery( + return ( // Servers stat what they are asked about, and a network path leaks the // Windows user's NTLM credentials to its host before any chip is clicked. - environmentId && canRead && absolute && !isNetworkPath(targetPath) && targetPath.length <= 512 + absolute && !isNetworkPath(targetPath) && targetPath.length <= 512 ? projectEnvironment.fileMetadata({ environmentId, input: { @@ -35,6 +38,18 @@ export function useFileMetadata( ...(threadRef?.environmentId === environmentId ? { threadId: threadRef.threadId } : {}), }, }) - : null, + : null + ); +} + +export function useFileMetadata( + environmentId: EnvironmentId | null | undefined, + path: string, + cwd?: string, +) { + const canRead = useEnvironmentScope(environmentId ?? null, AuthFilesystemReadScope); + const threadRef = useContext(FileMetadataThreadContext); + return useEnvironmentQuery( + environmentId && canRead ? getFileMetadataQueryAtom(environmentId, path, cwd, threadRef) : null, ).data; } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index ebbff4ae0f6fd..f4125793efe39 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -290,7 +290,10 @@ export function useThreadActionMenu(input: { } return; case "copy-thread": - void copyThreadReference(threadChipCopyTarget(threadRef, thread.title)); + void copyThreadReference( + // Coder: thread links open on the gateway's own origin; there is no hosted app. + threadChipCopyTarget(threadRef, thread.title, window.location.origin), + ); return; case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 307498bba36fe..d6d13fb7da448 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -545,6 +545,7 @@ export function useThreadActions() { deletedThreadId: threadRef.threadId, deletedThreadIds, sortOrder: sidebarThreadSortOrder, + now: new Date().toISOString(), }); const deleteResult = await deleteThreadMutation({ environmentId: threadRef.environmentId, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 7c5236cb5082f..95dfe1a2a7ffb 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2408,14 +2408,18 @@ code { padding: 0.05em 0.25em; } +/* Markdown delimiters shown, as editable text, around styled text with the caret in it. */ .composer-rich-marker { color: var(--muted-foreground); opacity: 0.75; font-weight: 400; font-style: normal; text-decoration: none; - user-select: none; - pointer-events: none; +} + +/* Delimiters of source restored by undo or redo, once the caret leaves it. */ +.composer-rich-marker-hidden { + display: none; } /* Selected chips (node selection from arrowing into or clicking a chip). */ diff --git a/apps/web/src/lib/assistantCitationNavigation.ts b/apps/web/src/lib/assistantCitationNavigation.ts index 1320b73aa33c5..3300d086d08c1 100644 --- a/apps/web/src/lib/assistantCitationNavigation.ts +++ b/apps/web/src/lib/assistantCitationNavigation.ts @@ -39,6 +39,13 @@ export function assistantCitationFromLocation(href: string) { } } +/** Return paths drop the quote hash so going back does not reveal its source again. */ +export function withoutAssistantCitationHash(href: string) { + const hashIndex = href.indexOf("#"); + if (hashIndex === -1 || !href.startsWith(CITATION_HASH_PREFIX, hashIndex + 1)) return href; + return href.slice(0, hashIndex); +} + /** A fresh activation lets the same link reveal its source again after dismissal. */ export function assistantCitationNavigation(citation: AssistantCitation) { return { diff --git a/apps/web/src/lib/browserCrypto.ts b/apps/web/src/lib/browserCrypto.ts index 3649234d977c8..569fd647789d0 100644 --- a/apps/web/src/lib/browserCrypto.ts +++ b/apps/web/src/lib/browserCrypto.ts @@ -1,16 +1,9 @@ -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Layer from "effect/Layer"; -export const browserCryptoLayer = Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)), - digest: (algorithm, data) => - Effect.promise(async () => { - const input = new Uint8Array(data.length); - input.set(data); - return new Uint8Array(await globalThis.crypto.subtle.digest(algorithm, input.buffer)); - }), - }), +// Coder: upstream's browser runtime takes these from `cloud/dpop.ts`'s layer, which is not carried. +export const browserCryptoLayer = Layer.mergeAll( + SecureRandom.layerWebCrypto, + Digest.layerWebCrypto, ); diff --git a/apps/web/src/lib/composerContextRecords.test.ts b/apps/web/src/lib/composerContextRecords.test.ts index 18c3f46f5647a..6a2695a2d980b 100644 --- a/apps/web/src/lib/composerContextRecords.test.ts +++ b/apps/web/src/lib/composerContextRecords.test.ts @@ -117,7 +117,7 @@ describe("composerContextRecords", () => { expect(removeInlineContextReference(prompt, reference.contextId).prompt).toBe("prose tail"); }); - it("clamps an oversized review selection so the record still encodes", () => { + it("clamps oversized review and terminal selections so the records still encode", () => { const build = (diffLength: number) => reviewCommentContextRecord({ id: "rc-big", @@ -138,6 +138,21 @@ describe("composerContextRecords", () => { expect(overLimit.diff.endsWith("… truncated …")).toBe(true); expect(() => decodeMessageContext({ version: 1, records: [atLimit] })).not.toThrow(); expect(() => decodeMessageContext({ version: 1, records: [overLimit] })).not.toThrow(); + + const terminal = terminalContextRecord({ + id: "term-big", + threadId: ThreadId.make("t"), + createdAt: "2026-01-01T00:00:00.000Z", + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 1, + lineEnd: 2_000, + text: `${"t".repeat(63_985)}${"😀".repeat(8)}`, + }); + expect(terminal.text).toBe(`${"t".repeat(63_985)}\n… truncated …`); + expect(decodeMessageContext({ version: 1, records: [terminal] }).records).toMatchObject([ + { kind: "terminal", text: terminal.text }, + ]); }); it("finds the destination collision for different legacy terminal messages", () => { diff --git a/apps/web/src/lib/composerContextRecords.ts b/apps/web/src/lib/composerContextRecords.ts index 4c95ca422773c..368d0f0e3c150 100644 --- a/apps/web/src/lib/composerContextRecords.ts +++ b/apps/web/src/lib/composerContextRecords.ts @@ -6,6 +6,7 @@ import { import { COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS, COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS, + COMPOSER_CONTEXT_TERMINAL_TEXT_MAX_CHARS, } from "@t3tools/contracts"; import type { ComposerContextId, @@ -48,7 +49,11 @@ const TRUNCATION_MARKER = "\n… truncated …"; function clampContextText(value: string, max: number): string { if (value.length <= max) return value; - return `${value.slice(0, Math.max(0, max - TRUNCATION_MARKER.length))}${TRUNCATION_MARKER}`; + let end = Math.max(0, max - TRUNCATION_MARKER.length); + // Never end on a lone high surrogate. + const last = value.charCodeAt(end - 1); + if (last >= 0xd800 && last <= 0xdbff) end -= 1; + return `${value.slice(0, end)}${TRUNCATION_MARKER}`; } type ReviewCommentPresentation = ReviewCommentContext | ReviewCommentContextRecord; @@ -159,7 +164,10 @@ export function terminalContextRecord(context: TerminalContextDraft): TerminalCo terminalLabel: context.terminalLabel, lineStart: context.lineStart, lineEnd: context.lineEnd, - text: normalizeTerminalContextText(context.text), + text: clampContextText( + normalizeTerminalContextText(context.text), + COMPOSER_CONTEXT_TERMINAL_TEXT_MAX_CHARS, + ), }; } diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index c27ddd9a04656..7d49d24a73fe8 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -11,8 +11,11 @@ import { ThreadId, } from "@t3tools/contracts"; import { makeThreadProjectionFixture } from "../test-fixtures"; -import { shouldOfferResumeCompaction } from "../components/chat/ContextWindowMeter.logic"; -import { deriveLatestContextWindowSnapshot, latestProviderTurnTokenUsage } from "./contextWindow"; +import { + deriveLatestContextWindowSnapshot, + latestProviderTurnTokenUsage, + shouldOfferResumeCompaction, +} from "@t3tools/client-runtime/state/context-window"; const decodeTokenUsage = Schema.decodeSync(OrchestrationV2ProviderTurnTokenUsage); diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 8c449249e0330..1cf3eee799bb2 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -1,179 +1,3 @@ -import type { ThreadStructure, ThreadStructureTurnItem } from "@t3tools/client-runtime/state/shell"; -import type { - OrchestrationV2ProviderTurn, - OrchestrationV2ProviderTurnTokenUsage, - OrchestrationV2ProviderThread, - ThreadTokenUsageSnapshot, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; - -function asFiniteNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - -type NullableContextWindowUsage = { - readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key] - ? Exclude | null - : ThreadTokenUsageSnapshot[Key]; -}; - -export type ContextWindowSnapshot = NullableContextWindowUsage & { - readonly remainingTokens: number | null; - readonly usedPercentage: number | null; - readonly remainingPercentage: number | null; - readonly updatedAt: string; -}; - -/** Newest report by timestamp; snapshots order turns by provider thread. */ -export function latestProviderTurnTokenUsage( - turns: ReadonlyArray>, - projection?: Pick | null, - providerThread?: Pick | null, -): OrchestrationV2ProviderTurnTokenUsage | null { - let latest: OrchestrationV2ProviderTurnTokenUsage | null = null; - for (const { tokenUsage } of turns) { - if (tokenUsage === undefined) continue; - // Ties go to the later entry, since live upserts append. - if (latest === null || Date.parse(tokenUsage.updatedAt) >= Date.parse(latest.updatedAt)) { - latest = tokenUsage; - } - } - if (latest !== null || projection == null || providerThread?.contextUsage != null) return latest; - const forkedFrom = projection.thread.forkedFrom; - if (forkedFrom?.type !== "run") return null; - return ( - projection.contextTransfers.find( - (transfer) => - transfer.type === "fork" && - transfer.targetThreadId === projection.thread.id && - transfer.sourceThreadId === forkedFrom.threadId && - transfer.sourcePoint.runId === forkedFrom.runId && - transfer.sourceProviderInstanceId === projection.thread.modelSelection.instanceId && - (transfer.status === "pending" || transfer.resolution?.strategy === "native_fork"), - )?.sourceTokenUsage ?? null - ); -} - -export function deriveLatestContextWindowSnapshot( - entries: ReadonlyArray<{ - readonly item: ThreadStructureTurnItem; - }>, - liveUsage?: OrchestrationV2ProviderTurnTokenUsage | null, - providerThread?: Pick | null, -): ContextWindowSnapshot | null { - if (liveUsage != null) { - const usedTokens = Math.max(0, liveUsage.usedTokens); - const maxTokens = liveUsage.maxTokens ?? null; - const usedPercentage = - maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; - const remainingTokens = - maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null; - const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null; - return { - usedTokens, - totalProcessedTokens: null, - maxTokens, - remainingTokens, - usedPercentage, - remainingPercentage, - inputTokens: liveUsage.inputTokens ?? null, - cachedInputTokens: liveUsage.cachedInputTokens ?? null, - outputTokens: liveUsage.outputTokens ?? null, - reasoningOutputTokens: liveUsage.reasoningOutputTokens ?? null, - lastUsedTokens: null, - lastInputTokens: null, - lastCachedInputTokens: null, - lastOutputTokens: null, - lastReasoningOutputTokens: null, - toolUses: null, - durationMs: null, - compactsAutomatically: true, - autoCompactThreshold: liveUsage.autoCompactThreshold ?? null, - cost: null, - updatedAt: liveUsage.updatedAt, - }; - } - const providerUsage = providerThread?.contextUsage; - const providerUsageUpdatedAt = providerThread?.updatedAt; - if ( - providerUsage !== null && - providerUsage !== undefined && - providerUsageUpdatedAt !== undefined - ) { - const maxTokens = asFiniteNumber(providerUsage.maxTokens); - const usedTokens = providerUsage.usedTokens; - const usedPercentage = - maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; - return { - usedTokens, - totalProcessedTokens: asFiniteNumber(providerUsage.totalProcessedTokens), - maxTokens, - remainingTokens: maxTokens === null ? null : Math.max(0, Math.round(maxTokens - usedTokens)), - usedPercentage, - remainingPercentage: usedPercentage === null ? null : Math.max(0, 100 - usedPercentage), - inputTokens: asFiniteNumber(providerUsage.inputTokens), - cachedInputTokens: asFiniteNumber(providerUsage.cachedInputTokens), - outputTokens: asFiniteNumber(providerUsage.outputTokens), - reasoningOutputTokens: asFiniteNumber(providerUsage.reasoningOutputTokens), - lastUsedTokens: asFiniteNumber(providerUsage.lastUsedTokens), - lastInputTokens: asFiniteNumber(providerUsage.lastInputTokens), - lastCachedInputTokens: asFiniteNumber(providerUsage.lastCachedInputTokens), - lastOutputTokens: asFiniteNumber(providerUsage.lastOutputTokens), - lastReasoningOutputTokens: asFiniteNumber(providerUsage.lastReasoningOutputTokens), - toolUses: asFiniteNumber(providerUsage.toolUses), - durationMs: asFiniteNumber(providerUsage.durationMs), - compactsAutomatically: providerUsage.compactsAutomatically ?? null, - cost: providerUsage.cost ?? null, - autoCompactThreshold: providerUsage.autoCompactThreshold ?? null, - updatedAt: DateTime.formatIso(providerUsageUpdatedAt), - }; - } - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (!entry || entry.item.type !== "compaction") { - continue; - } - const payload = entry.item; - const usedTokens = asFiniteNumber(payload.afterTokenCount); - if (usedTokens === null || usedTokens < 0) { - continue; - } - - const maxTokens = null; - const usedPercentage = - maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; - const remainingTokens = - maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null; - const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null; - - return { - usedTokens, - totalProcessedTokens: asFiniteNumber(payload.beforeTokenCount), - maxTokens, - remainingTokens, - usedPercentage, - remainingPercentage, - inputTokens: null, - cachedInputTokens: null, - outputTokens: null, - reasoningOutputTokens: null, - lastUsedTokens: null, - lastInputTokens: null, - lastCachedInputTokens: null, - lastOutputTokens: null, - lastReasoningOutputTokens: null, - toolUses: null, - durationMs: null, - compactsAutomatically: true, - autoCompactThreshold: null, - cost: null, - updatedAt: DateTime.formatIso(payload.startedAt ?? payload.updatedAt), - }; - } - - return null; -} - export function formatContextWindowTokens(value: number | null): string { if (value === null || !Number.isFinite(value)) { return "0"; diff --git a/apps/web/src/lib/sidebarTree.browser.test.ts b/apps/web/src/lib/sidebarTree.browser.test.ts index 6b02cd3b842bf..c6bb67bd2cb99 100644 --- a/apps/web/src/lib/sidebarTree.browser.test.ts +++ b/apps/web/src/lib/sidebarTree.browser.test.ts @@ -4,14 +4,12 @@ import { installSidebarTree } from "./sidebarTree"; function setup() { const root = document.createElement("div"); - root.setAttribute("role", "tree"); document.body.append(root); let dispose: (() => void) | undefined; const row = (id: string, parent?: string) => { const element = document.createElement("button"); element.dataset.sidebarRow = id; if (parent) element.dataset.sidebarParent = parent; - element.setAttribute("role", "treeitem"); element.textContent = id; root.append(element); return element; @@ -36,7 +34,7 @@ function press(target: HTMLElement, key: string, init: KeyboardEventInit = {}) { } describe("sidebar keyboard navigation", () => { - it("moves focus without activating rows, clamps at the ends, and keeps one Tab stop", () => { + it("moves focus without activating rows, clamps at the ends, and keeps every row in the Tab order", () => { using tree = setup(); const first = tree.row("first"); const second = tree.row("second"); @@ -54,10 +52,10 @@ describe("sidebar keyboard navigation", () => { press(last, "Home"); expect(document.activeElement).toBe(first); expect(activate).not.toHaveBeenCalled(); - expect(tree.root.querySelectorAll('[tabindex="0"]')).toHaveLength(1); + expect([first.tabIndex, second.tabIndex, last.tabIndex]).toEqual([0, 0, 0]); }); - it("expands a group before entering its first child and returns to the parent", async () => { + it("expands a group before entering its first child and returns to the parent", () => { using tree = setup(); const group = tree.row("project"); group.setAttribute("aria-expanded", "false"); @@ -72,12 +70,6 @@ describe("sidebar keyboard navigation", () => { press(group, "ArrowRight"); expect(group.getAttribute("aria-expanded")).toBe("true"); expect(document.activeElement).toBe(group); - // The tree picks up the new child from its mutation observer. - await vi.waitFor(() => - expect( - tree.root.querySelector('[data-sidebar-parent="project"]')?.getAttribute("aria-level"), - ).toBe("2"), - ); press(group, "ArrowRight"); const child = document.activeElement as HTMLElement; expect(child.dataset.sidebarRow).toBe("thread"); @@ -104,7 +96,7 @@ describe("sidebar keyboard navigation", () => { expect(document.activeElement).toBe(selected); }); - it("lets Tab reach only the focused row's buttons", () => { + it("leaves every row's buttons in the Tab order", () => { using tree = setup(); const first = tree.row("first"); const second = tree.row("second"); @@ -119,10 +111,10 @@ describe("sidebar keyboard navigation", () => { const secondAction = action("second"); tree.install(); first.focus(); - expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([0, -1]); + expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([0, 0]); press(first, "ArrowDown"); expect(document.activeElement).toBe(second); - expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([-1, 0]); + expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([0, 0]); }); it("keeps a rendered row under a collapsed group reachable", () => { @@ -152,26 +144,6 @@ describe("sidebar keyboard navigation", () => { expect(undo.tabIndex).toBe(0); }); - it("gives row buttons back their own tab order when the tree reinstalls", () => { - using tree = setup(); - tree.row("first"); - const second = tree.row("second"); - const action = (owner: string) => { - const button = document.createElement("button"); - button.dataset.sidebarActionFor = owner; - tree.root.append(button); - return button; - }; - const firstAction = action("first"); - const secondAction = action("second"); - const dispose = installSidebarTree(tree.root); - second.focus(); - dispose(); - expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([0, 0]); - tree.install(); - expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([-1, 0]); - }); - it("keeps the scroll position when focus follows a row that moved away", async () => { using tree = setup(); Object.assign(tree.root.style, { height: "100px", overflow: "auto" }); @@ -191,7 +163,7 @@ describe("sidebar keyboard navigation", () => { expect(tree.root.scrollTop).toBe(0); }); - it("does not pull focus back after a click outside the tree", async () => { + it("does not pull focus back after a click outside the rows", async () => { using tree = setup(); const first = tree.row("first"); tree.install(); @@ -199,16 +171,18 @@ describe("sidebar keyboard navigation", () => { text.textContent = "Chat text"; document.body.append(text); using _text = { [Symbol.dispose]: () => text.remove() }; - first.focus(); - await userEvent.click(text); - expect(document.activeElement).toBe(document.body); - // A status change re-renders the tree while the user works elsewhere. - tree.row("second"); - await vi.waitFor(() => - expect( - tree.root.querySelector('[data-sidebar-row="second"]')?.getAttribute("aria-posinset"), - ).toBe("2"), - ); - expect(document.activeElement).toBe(document.body); + const spacer = document.createElement("div"); + spacer.textContent = "Empty list space"; + tree.root.append(spacer); + for (const target of [text, spacer]) { + first.focus(); + await userEvent.click(target); + expect(document.activeElement).toBe(document.body); + // A status change re-renders the list while the user works elsewhere. + tree.row(`second-${target.tagName}`); + // The list's mutation observer callback is queued ahead of this continuation. + await Promise.resolve(); + expect(document.activeElement).toBe(document.body); + } }); }); diff --git a/apps/web/src/lib/sidebarTree.ts b/apps/web/src/lib/sidebarTree.ts index 07859c72bd901..7a6183b5e4acb 100644 --- a/apps/web/src/lib/sidebarTree.ts +++ b/apps/web/src/lib/sidebarTree.ts @@ -1,139 +1,82 @@ const ROW = "[data-sidebar-row]"; -const CONTROL = 'button, a[href], input, textarea, select, [contenteditable="true"], [tabindex]'; -const TEXT_ENTRY = 'input, textarea, select, [contenteditable="true"]'; -function setAttribute(element: Element, name: string, value: string) { - if (element.getAttribute(name) !== value) element.setAttribute(name, value); -} - -function setTabIndex(element: HTMLElement, value: number) { - if (element.tabIndex !== value) element.tabIndex = value; -} - -export const SIDEBAR_TREE_PROPS = { role: "tree", "aria-label": "Threads" } as const; - -/** Ref callback for a sidebar list; installs the tree and returns its cleanup. */ +/** Ref callback for a sidebar list; installs its keyboard navigation and returns its cleanup. */ export function attachSidebarTree(element: HTMLElement | null) { return element ? installSidebarTree(element) : undefined; } -/** A roving DOM focus, separate from thread activation, as in VS Code's list commands. */ +/** + * Arrow keys move focus between rows without opening them, as in VS Code's list commands. + * Rows stay ordinary buttons in the Tab order so screen readers can browse the list. + */ export function installSidebarTree(root: HTMLElement) { - let rows: HTMLElement[] = []; let focusedId: string | undefined; + let focusedParent: string | undefined; + let focusedIndex = -1; let ownsFocus = false; - let tabStop: HTMLElement | undefined; let contextMenuRow: HTMLElement | undefined; - // A control's own tab index, restored while its row holds the Tab stop and on uninstall. - const ownTabIndex = new Map(); - const rowControls = (row: HTMLElement) => { - const id = row.dataset.sidebarRow; - const linked = id - ? root.querySelectorAll(`[data-sidebar-action-for="${CSS.escape(id)}"]`) - : []; - return [...row.querySelectorAll(CONTROL), ...linked].filter( - (control) => !control.matches(ROW) && !control.matches(TEXT_ENTRY), - ); - }; - // Tab reaches the buttons of the focused row only, so the tree stays one stop for the rest. - const setRowControlsReachable = (row: HTMLElement, reachable: boolean) => { - for (const control of rowControls(row)) { - if (!ownTabIndex.has(control)) ownTabIndex.set(control, control.tabIndex); - setTabIndex(control, reachable ? ownTabIndex.get(control)! : -1); - } + const isVisibleRow = (row: HTMLElement) => { + const hidden = row.closest('[hidden], [inert], [aria-hidden="true"]'); + // A dialog can temporarily make the whole workspace inert. Keep rows navigable + // for its return while still excluding exiting row clones. + return (!hidden || !root.contains(hidden)) && !row.matches(":disabled"); }; + const visibleRows = () => + Array.from(root.querySelectorAll(ROW)).filter(isVisibleRow); - const setTabStop = (row: HTMLElement) => { - if (tabStop === row) return; - if (tabStop) { - setTabIndex(tabStop, -1); - setRowControlsReachable(tabStop, false); - } - setTabIndex(row, 0); - setRowControlsReachable(row, true); - tabStop = row; - focusedId = row.dataset.sidebarRow; + const ownerRow = (element: HTMLElement) => { + const row = element.closest(ROW); + if (row) return row; + const linked = element.dataset.sidebarActionFor; + return linked + ? root.querySelector(`[data-sidebar-row="${CSS.escape(linked)}"]`) + : null; }; const focusRow = (row: HTMLElement) => { - setTabStop(row); // Revealing before focusing also materializes content-visibility:auto descendants. row.scrollIntoView({ block: "nearest" }); row.focus({ preventScroll: true }); }; - const refresh = () => { - const previousRows = rows; - const previousIndex = previousRows.findIndex((row) => row.dataset.sidebarRow === focusedId); - const previous = previousRows[previousIndex]; - rows = Array.from(root.querySelectorAll(ROW)).filter((row) => { - const hidden = row.closest('[hidden], [inert], [aria-hidden="true"]'); - // A dialog can temporarily make the whole workspace inert. Keep the tree's - // Tab stop ready for its return while still excluding exiting row clones. - return (!hidden || !root.contains(hidden)) && !row.matches(":disabled"); - }); - let selected = + // A row that moves shelves remounts and drops focus to the body; follow it. + const restoreFocus = () => { + if (!ownsFocus || focusedId === undefined) return; + const active = document.activeElement; + const lost = + active === document.body || + (active instanceof HTMLElement && + root.contains(active) && + active.matches(ROW) && + !isVisibleRow(active)); + if (!lost) return; + const rows = visibleRows(); + const target = rows.find((row) => row.dataset.sidebarRow === focusedId) ?? - rows.find((row) => row === document.activeElement); - if (!selected) { - selected = - rows.find((row) => row.dataset.sidebarRow === previous?.dataset.sidebarParent) ?? - (previousIndex === -1 ? rows.find((row) => row.hasAttribute("aria-current")) : undefined) ?? - rows[Math.min(Math.max(previousIndex, 0), rows.length - 1)]; - } - focusedId = selected?.dataset.sidebarRow; - tabStop = selected; - setTabIndex(root, selected ? -1 : 0); - const siblings = new Map(); - for (const row of rows) { - const parent = row.dataset.sidebarParent ?? ""; - const group = siblings.get(parent) ?? []; - group.push(row); - siblings.set(parent, group); - setAttribute(row, "aria-level", parent ? "2" : "1"); - setTabIndex(row, row === selected ? 0 : -1); - } - const byId = new Map(rows.map((row) => [row.dataset.sidebarRow, row])); - for (const control of ownTabIndex.keys()) if (!control.isConnected) ownTabIndex.delete(control); - for (const control of root.querySelectorAll(CONTROL)) { - if (control.matches(ROW) || control.matches(TEXT_ENTRY)) continue; - const linked = control.dataset.sidebarActionFor; - const owner = control.closest(ROW) ?? (linked ? byId.get(linked) : undefined); - if (!owner) continue; - if (!ownTabIndex.has(control)) ownTabIndex.set(control, control.tabIndex); - setTabIndex(control, owner === selected ? ownTabIndex.get(control)! : -1); - } - for (const group of siblings.values()) - group.forEach((row, index) => { - setAttribute(row, "aria-posinset", String(index + 1)); - setAttribute(row, "aria-setsize", String(group.length)); - }); - for (const listItem of root.querySelectorAll("li:not([role])")) - listItem.setAttribute("role", "presentation"); - if ( - ownsFocus && - selected && - (document.activeElement === document.body || - document.activeElement === root || - (root.contains(document.activeElement) && - !rows.includes(document.activeElement as HTMLElement) && - document.activeElement?.matches(ROW))) - ) { - // A settled row moves to the bottom shelf; scrolling after it fights the route reveal. - selected.focus({ preventScroll: true }); - } - if (ownsFocus && !selected && document.activeElement === document.body) root.focus(); + rows.find((row) => row.dataset.sidebarRow === focusedParent) ?? + rows[Math.min(Math.max(focusedIndex, 0), rows.length - 1)]; + // A settled row moves to the bottom shelf; scrolling after it fights the route reveal. + target?.focus({ preventScroll: true }); }; - // Clicking non-focusable content drops focus to the body without a focusin event. + // Clicking non-focusable content, including empty list space, drops focus to the body + // without a focusin event. A click on the focused row fires no focusin either. const onPointerDown = (event: PointerEvent) => { - if (!root.contains(event.target as Node)) ownsFocus = false; + const focusable = + event.target instanceof Element + ? event.target.closest(`${ROW}, button, a[href], input, [tabindex]`) + : null; + ownsFocus = focusable !== null && root.contains(focusable); }; const onFocus = (event: FocusEvent) => { ownsFocus = root.contains(event.target as Node); - if (!ownsFocus || !(event.target instanceof HTMLElement) || !event.target.matches(ROW)) return; - setTabStop(event.target); + if (!ownsFocus || !(event.target instanceof HTMLElement)) return; + const row = ownerRow(event.target); + if (!row) return; + focusedId = row.dataset.sidebarRow; + focusedParent = row.dataset.sidebarParent; + focusedIndex = visibleRows().indexOf(row); }; const onKeyDown = (event: KeyboardEvent) => { const row = event.target; @@ -162,6 +105,7 @@ export function installSidebarTree(root: HTMLElement) { ) return; if (!(row instanceof HTMLElement) || !row.matches(ROW)) return; + const rows = visibleRows(); const index = rows.indexOf(row); if (index < 0) return; let target: HTMLElement | undefined; @@ -219,34 +163,12 @@ export function installSidebarTree(root: HTMLElement) { }), ); }; - refresh(); - const observer = new MutationObserver((records) => { - if ( - records.some( - (record) => - record.type === "attributes" || - [...record.addedNodes, ...record.removedNodes].some( - (node) => - node instanceof Element && - (node.matches(`${ROW}, ${CONTROL}`) || node.querySelector(`${ROW}, ${CONTROL}`)), - ), - ) - ) - refresh(); - }); + const observer = new MutationObserver(restoreFocus); observer.observe(root, { childList: true, subtree: true, attributes: true, - attributeFilter: [ - "data-sidebar-row", - "data-sidebar-parent", - "aria-expanded", - "hidden", - "disabled", - "inert", - "aria-hidden", - ], + attributeFilter: ["hidden", "inert", "aria-hidden"], }); document.addEventListener("focusin", onFocus); document.addEventListener("pointerdown", onPointerDown, true); @@ -254,8 +176,6 @@ export function installSidebarTree(root: HTMLElement) { root.addEventListener("keyup", onKeyUp, true); return () => { observer.disconnect(); - // A later install reads these as the controls' own tab indexes. - for (const [control, tabIndex] of ownTabIndex) setTabIndex(control, tabIndex); document.removeEventListener("focusin", onFocus); document.removeEventListener("pointerdown", onPointerDown, true); root.removeEventListener("keydown", onKeyDown, true); diff --git a/apps/web/src/lib/workspaceFocus.ts b/apps/web/src/lib/workspaceFocus.ts index dcdb8ffdabd89..810d57a9d604e 100644 --- a/apps/web/src/lib/workspaceFocus.ts +++ b/apps/web/src/lib/workspaceFocus.ts @@ -114,7 +114,7 @@ export function createWorkspaceFocusController(doc: Document) { !element.matches(":disabled"); const preferred = Array.from( area.querySelectorAll( - '[data-workspace-focus-default], [role="treeitem"][tabindex="0"], [data-settings-section][tabindex="0"], .xterm-helper-textarea, [contenteditable="true"]', + '[data-workspace-focus-default], [data-sidebar-row][aria-current="page"], [data-settings-section][tabindex="0"], .xterm-helper-textarea, [contenteditable="true"]', ), ).find(belongs); const fallback = Array.from(area.querySelectorAll(FOCUSABLE_SELECTOR)).find( diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index b75291d47f769..ac7afbc8145ac 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -206,6 +206,32 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("-\n\n```\npnpm test\n```"); }); + it("keeps blank lines inside a code block in a list item, quote, or longer fence", () => { + const code = ["import os", "", "", "def main():"]; + const list = new FakeElement("OL").append( + new FakeElement("LI").append(new FakeText("Create it:\n"), renderedCodeBlock(code)), + ); + const quote = new FakeElement("BLOCKQUOTE").append( + new FakeElement("P").append(new FakeText("Note:")), + renderedCodeBlock(code), + ); + const nestedFence = new FakeElement("DIV").append( + new FakeElement("P").append(new FakeText("Example:")), + renderedCodeBlock(["```js", "a()", "", "", "b() ", "```"]), + ); + + expect(serializeRenderedMarkdownFragment(asNode(nestedFence))).toBe( + "Example:\n\n````\n```js\na()\n\n\nb() \n```\n````", + ); + + expect(serializeRenderedMarkdownFragment(asNode(new FakeElement("DIV").append(list)))).toBe( + "1. Create it:\n ```\n import os\n\n\n def main():\n ```", + ); + expect(serializeRenderedMarkdownFragment(asNode(new FakeElement("DIV").append(quote)))).toBe( + "> Note:\n>\n> ```\n> import os\n>\n>\n> def main():\n> ```", + ); + }); + it("omits fences when a selection past the last line drags in the whole code block", () => { const container = new FakeElement("DIV").append( renderedCodeBlock(["printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN"]), diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 62bbde6d71667..2364a6c77c4e5 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -57,6 +57,14 @@ function codeFenceFor(code: string): string { return "`".repeat(Math.max(3, longestRun + 1)); } +function outsideCodeFences(markdown: string, tidy: (prose: string) => string): string { + // A fence closes only at its own opening run, so split yields [prose, fence, run] triples. + return markdown + .split(/((`{3,})[\s\S]*?(?:\2|$))/) + .map((part, index) => (index % 3 === 0 ? tidy(part) : index % 3 === 1 ? part : "")) + .join(""); +} + function resolveCodeBlockLanguage(pre: Element): string | null { const declared = pre.closest("[data-language]")?.getAttribute("data-language") ?? @@ -120,11 +128,11 @@ function serializeListItem( const task = checkbox ? `[${(checkbox as HTMLInputElement).checked ? "x" : " "}] ` : ""; const bullet = ordered ? `${index}. ` : "- "; const marker = `${bullet}${task}`; - let content = serializeChildren(item, inTableCell) - .replace(/\n{3,}/g, "\n\n") - .trim(); + let content = outsideCodeFences(serializeChildren(item, inTableCell), (prose) => + prose.replace(/\n{3,}/g, "\n\n"), + ).trim(); if (!item.querySelector(":scope > p")) { - content = content.replace(/\n{2,}/g, "\n"); + content = outsideCodeFences(content, (prose) => prose.replace(/\n{2,}/g, "\n")); } // Continuations align after the bullet; task boxes belong to the item text. const continuationIndent = " ".repeat(bullet.length); @@ -143,9 +151,9 @@ function serializeList(list: Element, ordered: boolean, inTableCell: boolean): s } function serializeBlockquote(quote: Element, inTableCell: boolean): string { - const content = serializeChildren(quote, inTableCell) - .replace(/\n{3,}/g, "\n\n") - .trim(); + const content = outsideCodeFences(serializeChildren(quote, inTableCell), (prose) => + prose.replace(/\n{3,}/g, "\n\n"), + ).trim(); if (!content) return ""; const quoted = content .split("\n") @@ -316,13 +324,9 @@ function soleCodeBlock(container: Node): Element | null { } function tidyMarkdown(markdown: string): string { - return markdown - .split(/(```[\s\S]*?(?:```|$))/) - .map((part, index) => - index % 2 === 1 ? part : part.replace(/[ \t]+(?=\n)/g, "").replace(/\n{3,}/g, "\n\n"), - ) - .join("") - .trim(); + return outsideCodeFences(markdown, (prose) => + prose.replace(/[ \t]+(?=\n)/g, "").replace(/\n{3,}/g, "\n\n"), + ).trim(); } export function serializeRenderedMarkdownFragment(container: Node): string { diff --git a/apps/web/src/reviewCommentContext.test.ts b/apps/web/src/reviewCommentContext.test.ts index b17d4ef106e03..1b5e64373bf1a 100644 --- a/apps/web/src/reviewCommentContext.test.ts +++ b/apps/web/src/reviewCommentContext.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildDiffReviewComment, buildFileReviewComment, + buildReviewCommentRenderablePatch, formatReviewCommentFence, resolveDiffReviewAnchor, resolveDiffReviewPosition, @@ -75,6 +76,38 @@ describe("review comment context parsing", () => { ); }); + it("keeps a selection's trailing blank line and whitespace in the rendered excerpt", () => { + const fileDiff = parsePatchFiles( + [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1,4 +1,4 @@", + " one", + "-two", + "+two\t", + " ", + " four", + ].join("\n"), + "review-comment-trailing-blank", + )[0]!.files[0]!; + const comment = (end: number) => + buildDiffReviewComment({ + id: "comment-blank", + sectionId: "turn:1", + sectionTitle: "Turn 1", + filePath: "src/app.ts", + fileDiff, + range: { start: 2, side: "additions", end, endSide: "additions" }, + text: "Insert here.", + })!; + + expect(buildReviewCommentRenderablePatch(comment(2))).toMatch(/\n\+two\t$/); + expect(buildReviewCommentRenderablePatch(comment(3))).toMatch( + /\n@@ -3,1 \+2,2 @@\n\+two\t\n $/, + ); + }); + it("restores Pierre line selections from persisted diff comment row indexes", () => { const fileDiff = parsePatchFiles( [ diff --git a/apps/web/src/reviewCommentContext.ts b/apps/web/src/reviewCommentContext.ts index 336a506a52676..b169dd41885cf 100644 --- a/apps/web/src/reviewCommentContext.ts +++ b/apps/web/src/reviewCommentContext.ts @@ -512,10 +512,10 @@ export function buildReviewCommentRenderablePatch(comment: ReviewCommentContext) if ((comment.fenceLanguage ?? "diff") !== "diff") { return ""; } - const diff = comment.diff.trim(); - if (diff.length === 0) { + if (comment.diff.trim().length === 0) { return ""; } + const diff = comment.diff.trimStart().replace(/\n+$/, ""); if (diff.startsWith("diff --git ")) { return diff; } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 5781356b7bd76..471d60991d379 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' +import { Route as ThreadsEnvironmentIdThreadIdRouteImport } from './routes/threads.$environmentId.$threadId' import { Route as ChatSettingsWorktreesRouteImport } from './routes/_chat.settings.worktrees' import { Route as ChatSettingsThreadsRouteImport } from './routes/_chat.settings.threads' import { Route as ChatSettingsStorageRouteImport } from './routes/_chat.settings.storage' @@ -60,6 +61,12 @@ const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({ path: '/pull-requests', getParentRoute: () => ChatRoute, } as any) +const ThreadsEnvironmentIdThreadIdRoute = + ThreadsEnvironmentIdThreadIdRouteImport.update({ + id: '/threads/$environmentId/$threadId', + path: '/threads/$environmentId/$threadId', + getParentRoute: () => rootRouteImport, + } as any) const ChatSettingsWorktreesRoute = ChatSettingsWorktreesRouteImport.update({ id: '/worktrees', path: '/worktrees', @@ -196,6 +203,7 @@ export interface FileRoutesByFullPath { '/settings/storage': typeof ChatSettingsStorageRoute '/settings/threads': typeof ChatSettingsThreadsRoute '/settings/worktrees': typeof ChatSettingsWorktreesRoute + '/threads/$environmentId/$threadId': typeof ThreadsEnvironmentIdThreadIdRoute } export interface FileRoutesByTo { '/pull-requests': typeof ChatPullRequestsRoute @@ -223,6 +231,7 @@ export interface FileRoutesByTo { '/settings/storage': typeof ChatSettingsStorageRoute '/settings/threads': typeof ChatSettingsThreadsRoute '/settings/worktrees': typeof ChatSettingsWorktreesRoute + '/threads/$environmentId/$threadId': typeof ThreadsEnvironmentIdThreadIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -252,6 +261,7 @@ export interface FileRoutesById { '/_chat/settings/storage': typeof ChatSettingsStorageRoute '/_chat/settings/threads': typeof ChatSettingsThreadsRoute '/_chat/settings/worktrees': typeof ChatSettingsWorktreesRoute + '/threads/$environmentId/$threadId': typeof ThreadsEnvironmentIdThreadIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -281,6 +291,7 @@ export interface FileRouteTypes { | '/settings/storage' | '/settings/threads' | '/settings/worktrees' + | '/threads/$environmentId/$threadId' fileRoutesByTo: FileRoutesByTo to: | '/pull-requests' @@ -308,6 +319,7 @@ export interface FileRouteTypes { | '/settings/storage' | '/settings/threads' | '/settings/worktrees' + | '/threads/$environmentId/$threadId' id: | '__root__' | '/_chat' @@ -336,11 +348,13 @@ export interface FileRouteTypes { | '/_chat/settings/storage' | '/_chat/settings/threads' | '/_chat/settings/worktrees' + | '/threads/$environmentId/$threadId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute + ThreadsEnvironmentIdThreadIdRoute: typeof ThreadsEnvironmentIdThreadIdRoute } declare module '@tanstack/react-router' { @@ -380,6 +394,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatPullRequestsRouteImport parentRoute: typeof ChatRoute } + '/threads/$environmentId/$threadId': { + id: '/threads/$environmentId/$threadId' + path: '/threads/$environmentId/$threadId' + fullPath: '/threads/$environmentId/$threadId' + preLoaderRoute: typeof ThreadsEnvironmentIdThreadIdRouteImport + parentRoute: typeof rootRouteImport + } '/_chat/settings/worktrees': { id: '/_chat/settings/worktrees' path: '/worktrees' @@ -599,6 +620,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, + ThreadsEnvironmentIdThreadIdRoute: ThreadsEnvironmentIdThreadIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/threads.$environmentId.$threadId.tsx b/apps/web/src/routes/threads.$environmentId.$threadId.tsx new file mode 100644 index 0000000000000..e3583b0737fdc --- /dev/null +++ b/apps/web/src/routes/threads.$environmentId.$threadId.tsx @@ -0,0 +1,9 @@ +import { Navigate, createFileRoute } from "@tanstack/react-router"; + +// A copied thread link. On the hosted app its chooser shows first; this opens the thread after it. +export const Route = createFileRoute("/threads/$environmentId/$threadId")({ + component: function ThreadLinkRedirect() { + const params = Route.useParams(); + return ; + }, +}); diff --git a/apps/web/src/shortcuts/chatRouteCommands.test.ts b/apps/web/src/shortcuts/chatRouteCommands.test.ts new file mode 100644 index 0000000000000..6c9ff18690ec4 --- /dev/null +++ b/apps/web/src/shortcuts/chatRouteCommands.test.ts @@ -0,0 +1,65 @@ +import { DEFAULT_CLIENT_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { AnyRouter } from "@tanstack/react-router"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import * as commandPalette from "../commandPaletteBus"; +import * as settings from "../hooks/useSettings"; +import * as threadActions from "../lib/chatThreadActions"; +import * as entities from "../state/entities"; +import type { Project } from "../types"; +import { installChatRouteCommands } from "./chatRouteCommands"; +import * as dispatcher from "./dispatcher"; + +function project(id: string): Project { + return { + id: ProjectId.make(id), + environmentId: EnvironmentId.make("local"), + title: id, + workspaceRoot: `/projects/${id}`, + repositoryIdentity: null, + defaultModelSelection: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [], + }; +} + +describe("new thread commands", () => { + it.each([ + { mode: "normal", count: 2, command: "chat.new", picker: true }, + { mode: "boring", count: 2, command: "chat.new", picker: true }, + { mode: "boring", count: 1, command: "chat.new", picker: false }, + { mode: "legacy", count: 2, command: "chat.new", picker: false }, + { mode: "boring", count: 2, command: "chat.newLocal", picker: false }, + ] as const)("$mode sidebar, $count projects, $command: picker=$picker", (scenario) => { + using _settings = vi.spyOn(settings, "getClientSettings").mockReturnValue({ + ...DEFAULT_CLIENT_SETTINGS, + boringSidebarEnabled: scenario.mode === "boring", + legacySidebarEnabled: scenario.mode === "legacy", + }); + using _projects = vi + .spyOn(entities, "readProjects") + .mockReturnValue([project("active"), project("idle")].slice(0, scenario.count)); + using openPicker = vi.spyOn(commandPalette, "openCommandPalette").mockImplementation(() => {}); + using startLocal = vi.spyOn(threadActions, "startNewThreadFromContext").mockResolvedValue(true); + using register = vi.spyOn(dispatcher, "registerCommands").mockReturnValue(() => {}); + const router = { state: { matches: [] } } as unknown as AnyRouter; + using stack = new DisposableStack(); + stack.defer(installChatRouteCommands(router)); + const commands = register.mock.calls[0]![0]; + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + + commands[scenario.command]!(event); + + if (scenario.picker) { + expect(openPicker).toHaveBeenCalledWith({ open: "new-thread-in" }); + expect(startLocal).not.toHaveBeenCalled(); + } else { + expect(openPicker).not.toHaveBeenCalled(); + expect(startLocal).toHaveBeenCalledOnce(); + } + }); +}); diff --git a/apps/web/src/shortcuts/chatRouteCommands.ts b/apps/web/src/shortcuts/chatRouteCommands.ts index a5b333c2c7f60..6f1a233f6132f 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -99,12 +99,9 @@ export function installChatRouteCommands(router: AnyRouter): () => void { void scratch.startScratchThread(environmentId); }, "chat.new": (event) => { - // The boring sidebar's folders already show the context, so it starts in - // the current project like the legacy sidebar did. - const { boringSidebarEnabled, legacySidebarEnabled } = getClientSettings(); + const { legacySidebarEnabled } = getClientSettings(); const groups = readProjectGroups(); if ( - !boringSidebarEnabled && !legacySidebarEnabled && !shouldCreateNewThreadInCurrentProject( false, diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 4dbd65b0abc51..5f9f7d981da7d 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -1,4 +1,8 @@ -import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; +import type { + EnvironmentId, + ScopedProjectRef, + SidebarProjectGroupingMode, +} from "@t3tools/contracts"; import { buildProjectGroups, derivePhysicalProjectKey, @@ -7,6 +11,23 @@ import { } from "@t3tools/client-runtime/state/project-grouping"; import type { Project } from "./types"; +export const PROJECT_GROUPING_MODE_LABELS: Record = { + repository: "Group by repository", + repository_path: "Group by repository path", + separate: "Keep separate", +}; + +export function withProjectGroupingOverride( + overrides: Readonly> | undefined, + overrideKey: string, + selection: SidebarProjectGroupingMode | "inherit", +): Record { + const next = { ...overrides }; + if (selection === "inherit") delete next[overrideKey]; + else next[overrideKey] = selection; + return next; +} + export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; export interface SidebarProjectGroupMember extends Project { diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index f5ddb556c783b..1719e00f38395 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -98,7 +98,3 @@ export function useEnvironmentMachines() { export function useConnectedEnvironmentIds() { return useAtomValue(environmentSummaries.connectedEnvironmentIdsAtom); } - -export function useEnvironmentConnectionSummaries() { - return useAtomValue(environmentSummaries.environmentsAtom); -} diff --git a/apps/web/src/state/orchestration.ts b/apps/web/src/state/orchestration.ts index 8c6e1738857a7..49ba6867143b1 100644 --- a/apps/web/src/state/orchestration.ts +++ b/apps/web/src/state/orchestration.ts @@ -1,5 +1,8 @@ import { createOrchestrationEnvironmentAtoms } from "@t3tools/client-runtime/state/orchestration"; import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentShell } from "./shell"; -export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime); +export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime, { + shellState: environmentShell.stateValueAtom, +}); diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index d0b1e67de2678..c7e938524fa9d 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -537,6 +537,150 @@ describe("GhosttyTerminalSurface visibility", () => { expect(onLinkActivate).not.toHaveBeenCalled(); }); + it.each([ + { scenario: "missing", exists: false }, + { scenario: "file", exists: true }, + { scenario: "replaced", exists: true }, + { scenario: "superseded", exists: false }, + { scenario: "hidden", exists: false }, + { scenario: "dragged", exists: true }, + { scenario: "keyboard-selected", exists: false }, + { scenario: "selection-cleared", exists: true }, + { scenario: "output-cleaned", exists: true }, + ])( + "validates a quick click without waiting for hover: $scenario", + async ({ scenario, exists }) => { + vi.stubGlobal("navigator", { platform: "Linux x86_64" }); + const harness = createHarness(); + let resolveValidation!: (exists: boolean) => void; + const validation = new Promise((resolve) => { + resolveValidation = resolve; + }); + const validateLink = vi.fn(() => validation); + const onLinkActivate = vi.fn(); + const surface = await harness.create({ + beforeKey: () => true, + canActivateLink: () => false, + validateLink, + onLinkActivate, + }); + surface.write("src/main.ts"); + harness.flushFrame(); + surface.selectAll(); + expect(surface.getSelection()).toBe("src/main.ts"); + harness.pointer("pointerdown", 5, 1); + expect(validateLink).toHaveBeenCalledWith("src/main.ts"); + if (scenario === "dragged") harness.pointer("pointermove", 45, 1); + harness.pointer("pointerup", 5, 0); + expect(onLinkActivate).not.toHaveBeenCalled(); + if (scenario === "replaced") { + surface.write("\r\x1b[2Ksrc/other.ts"); + harness.flushFrame(); + } + if (scenario === "superseded") harness.pointer("pointerdown", 5, 1); + if (scenario === "hidden") { + surface.setVisible(false); + surface.setVisible(true); + } + if (scenario === "keyboard-selected") + key(surface, "a", "KeyA", { ctrlKey: true, shiftKey: true }); + if (scenario === "selection-cleared") surface.clearSelection(); + if (scenario === "output-cleaned") surface.clearSelection({ source: "output" }); + resolveValidation(exists); + await validation; + expect(onLinkActivate).toHaveBeenCalledTimes( + scenario === "file" || scenario === "output-cleaned" ? 1 : 0, + ); + if (scenario === "missing" || scenario === "selection-cleared") + expect(surface.getSelection()).toBe(""); + if ( + scenario === "file" || + scenario === "hidden" || + scenario === "superseded" || + scenario === "keyboard-selected" + ) { + expect(surface.getSelection()).not.toBe(""); + } + }, + ); + + it("only requests validation for a stable visible link under the pointer", async () => { + const harness = createHarness(); + const onLinkHover = vi.fn(); + const surface = await harness.create({ onLinkHover, canActivateLink: () => false }); + surface.write("\x1b[?1049h\x1b[Hsrc/first.ts"); + harness.flushFrame(); + vi.advanceTimersByTime(250); + expect(onLinkHover).not.toHaveBeenCalled(); + + harness.pointer("pointermove", 5, 0); + expect(onLinkHover).toHaveBeenLastCalledWith("src/first.ts"); + onLinkHover.mockClear(); + for (let index = 0; index < 100; index++) { + surface.write(`\r\x1b[2Ksrc/file-${index}.ts`); + harness.flushFrame(); + vi.advanceTimersByTime(10); + } + vi.advanceTimersByTime(239); + expect(onLinkHover.mock.calls.filter(([text]) => text !== null)).toEqual([]); + vi.advanceTimersByTime(1); + harness.flushFrame(); + vi.advanceTimersByTime(1_000); + expect(onLinkHover.mock.calls.filter(([text]) => text !== null)).toEqual([["src/file-99.ts"]]); + onLinkHover.mockClear(); + + harness.pointer("pointerleave", 5, 0); + surface.write("\r\x1b[2Ksrc/recent.ts"); + harness.flushFrame(); + vi.advanceTimersByTime(150); + harness.pointer("pointermove", 5, 0); + vi.advanceTimersByTime(99); + expect(onLinkHover.mock.calls.filter(([text]) => text !== null)).toEqual([]); + vi.advanceTimersByTime(1); + expect(onLinkHover).toHaveBeenLastCalledWith("src/recent.ts"); + onLinkHover.mockClear(); + + harness.pointer("pointerleave", 5, 0); + surface.write("\r\x1b[2Ksrc/still.ts"); + harness.flushFrame(); + vi.advanceTimersByTime(250); + surface.write("\x1b[2;1Hother output"); + harness.flushFrame(); + harness.pointer("pointermove", 5, 0); + expect(onLinkHover).toHaveBeenLastCalledWith("src/still.ts"); + onLinkHover.mockClear(); + + surface.write("\x1b[H\x1b[2Ksrc/move.ts"); + harness.flushFrame(); + harness.pointer("pointermove", 45, 0); + vi.advanceTimersByTime(150); + surface.write("\r\x1b[2K src/move.ts"); + harness.flushFrame(); + vi.advanceTimersByTime(249); + expect(onLinkHover.mock.calls.filter(([text]) => text !== null)).toEqual([]); + vi.advanceTimersByTime(1); + expect(onLinkHover).toHaveBeenLastCalledWith("src/move.ts"); + onLinkHover.mockClear(); + + for (const [index, cancel] of [ + () => { + surface.write("\r\x1b[2Kplain text"); + harness.flushFrame(); + }, + () => harness.pointer("pointerleave", 5, 0), + () => surface.setVisible(false), + () => surface.dispose(), + ].entries()) { + surface.setVisible(true); + surface.write(`\x1b[H\x1b[2Ksrc/next-${index}.ts`); + harness.flushFrame(); + harness.pointer("pointermove", 5, 0); + cancel(); + vi.advanceTimersByTime(250); + expect(onLinkHover.mock.calls.filter(([text]) => text !== null)).toEqual([]); + } + }); + it.each([false, true])( "uses visibility %s if it changes while WASM initializes", async (visible) => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index a1a762cd8a7ef..f1db43056f76f 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -35,6 +35,7 @@ const DEFAULT_TERMINAL_FONT_FAMILY = const CONTENT_PADDING = 4; const MIN_SCROLLBAR_THUMB_HEIGHT = 18; const CURSOR_BLINK_INTERVAL_MS = 500; +const TERMINAL_LINK_HOVER_DELAY_MS = 250; /** End an unterminated synchronized update instead of freezing the last frame. */ const SYNCHRONIZED_OUTPUT_RENDER_TIMEOUT_MS = 500; const TERMINAL_FONT_LOAD_TEXT = "iMW0@# ."; @@ -525,6 +526,9 @@ export interface GhosttyTerminalSurfaceOptions { readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; readonly canActivateLink?: (text: string) => boolean; + readonly validateLink?: (text: string) => Promise; + /** Reports hovered candidates once their rendered text is stable for 250 ms; null cancels. */ + readonly onLinkHover?: (text: string | null) => void; /** The host must prevent the default menu; native Paste cannot reach a canvas terminal. */ readonly onContextMenu?: (event: MouseEvent) => void; } @@ -585,6 +589,8 @@ export class GhosttyTerminalSurface { private mouseReportingButton: number | null = null; private linkActivationPointerId: number | null = null; private linkActivationLink: TerminalLinkWithRange | null = null; + private linkActivationValidation: Promise | undefined; + private linkActivationRequestId = 0; private linkActivationOrigin: { x: number; y: number; @@ -592,6 +598,12 @@ export class GhosttyTerminalSurface { } | null = null; private hoveredLink: TerminalLinkWithRange | null = null; private hoverPointer: { x: number; y: number } | null = null; + private linkHoverCandidate: TerminalLinkWithRange | null = null; + private linkHoverTimer: number | null = null; + private linkRowStability: Array<{ + row: GhosttySnapshot["rowData"][number]; + changedAt: number; + }> = []; private selectionClickSequence: TerminalSelectionClickSequence | null = null; private selectionMoved = false; private composing = false; @@ -740,6 +752,9 @@ export class GhosttyTerminalSurface { this.forceFullRender = true; this.scrollbarDirty = true; if (!visible) { + this.linkActivationRequestId += 1; + this.clearHoveredLink(); + this.linkRowStability = []; this.cancelRender(); this.setSelectionAutoscroll(0); return; @@ -991,6 +1006,7 @@ export class GhosttyTerminalSurface { } selectAll(): void { + this.linkActivationRequestId += 1; this.clearPrimedCopy(); const range = this.core.selectAll(); this.selectionAnchorScreen = range?.start ?? null; @@ -1033,7 +1049,8 @@ export class GhosttyTerminalSurface { }; } - clearSelection(): void { + clearSelection({ source = "user" }: { source?: "user" | "output" } = {}): void { + if (source === "user") this.linkActivationRequestId += 1; this.clearPrimedCopy(); this.core.clearSelection(); this.selectionEnd = null; @@ -1061,6 +1078,7 @@ export class GhosttyTerminalSurface { dispose(): void { if (this.disposed) return; this.disposed = true; + this.clearHoveredLink(); this.stopObservingResize(); document.fonts.removeEventListener("loadingdone", this.onFontsLoaded); this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); @@ -1344,6 +1362,7 @@ export class GhosttyTerminalSurface { } private readonly onPointerDown = (event: PointerEvent) => { + this.linkActivationRequestId += 1; if (event.pointerType === "touch") { event.preventDefault(); if (!event.isPrimary || this.touchScroll !== null) return; @@ -1379,12 +1398,13 @@ export class GhosttyTerminalSurface { } if (event.button !== 0) return; const clickCount = this.recordSelectionClick(event); - const link = this.linkAt(event.clientX, event.clientY); + const link = this.linkAt(event.clientX, event.clientY, true); if (link && !event.shiftKey && clickCount === 1) { event.preventDefault(); event.stopPropagation(); this.linkActivationPointerId = event.pointerId; this.linkActivationLink = link; + this.linkActivationValidation = this.validateLink(link.text); this.linkActivationOrigin = { x: event.clientX, y: event.clientY, @@ -1470,6 +1490,7 @@ export class GhosttyTerminalSurface { } this.linkActivationPointerId = null; this.linkActivationLink = null; + this.linkActivationValidation = undefined; this.linkActivationOrigin = null; this.clearHoveredLink(); this.beginSelection( @@ -1577,14 +1598,65 @@ export class GhosttyTerminalSurface { private clearHoveredLink(cursor = ""): void { this.hoverPointer = null; + this.updateLinkHover(null); this.setHoveredLink(null); this.canvas.style.cursor = cursor; } private refreshHoveredLink(): void { const pointer = this.hoverPointer; - const link = pointer ? this.linkAt(pointer.x, pointer.y) : null; - this.setHoveredLink(link); + const link = pointer ? this.findLinkAt(pointer.x, pointer.y) : null; + this.updateLinkHover(link); + this.setHoveredLink(link && this.options.canActivateLink?.(link.text) !== false ? link : null); + } + + private updateLinkHover(link: TerminalLinkWithRange | null): void { + if (!this.options.onLinkHover) return; + if (link ? isSameTerminalLink(link, this.linkHoverCandidate) : this.linkHoverCandidate === null) + return; + this.linkHoverCandidate = link; + if (this.linkHoverTimer !== null) { + window.clearTimeout(this.linkHoverTimer); + this.linkHoverTimer = null; + } + this.options.onLinkHover(null); + if (link !== null) { + const now = performance.now(); + let changedAt = 0; + for (let y = link.range.start.y; y <= link.range.end.y; y++) { + changedAt = Math.max(changedAt, this.linkRowStability[y]?.changedAt ?? now); + } + const delay = Math.max(0, TERMINAL_LINK_HOVER_DELAY_MS - (now - changedAt)); + if (delay === 0) { + this.options.onLinkHover(link.text); + return; + } + this.linkHoverTimer = window.setTimeout(() => { + this.linkHoverTimer = null; + this.options.onLinkHover?.(link.text); + }, delay); + } + } + + private updateLinkRowStability(reset: boolean): void { + if (!this.options.onLinkHover || !this.snapshot) return; + if (reset) { + this.updateLinkHover(null); + this.linkRowStability = []; + } + const now = performance.now(); + this.snapshot.rowData.forEach((row, y) => { + const previous = this.linkRowStability[y]?.row; + if ( + !previous || + previous.text !== row.text || + previous.cells.length !== row.cells.length || + previous.isWrapContinuation !== row.isWrapContinuation || + previous.wrapsToNext !== row.wrapsToNext + ) + this.linkRowStability[y] = { row, changedAt: now }; + }); + this.linkRowStability.length = this.snapshot.rows; } private setHoveredLink(link: TerminalLinkWithRange | null): void { @@ -1617,8 +1689,8 @@ export class GhosttyTerminalSurface { this.sendMouse("press", ghosttyMouseButton(0), event); this.sendMouse("release", ghosttyMouseButton(0), event); } else { - const link = this.linkAt(event.clientX, event.clientY); - if (link) this.options.onLinkActivate(link.text, event); + const link = this.linkAt(event.clientX, event.clientY, true); + if (link) this.activateLink(link, event); else this.clearSelection(); } return; @@ -1628,15 +1700,17 @@ export class GhosttyTerminalSurface { event.preventDefault(); event.stopPropagation(); const link = this.linkActivationLink; + const validation = this.linkActivationValidation; this.linkActivationPointerId = null; this.linkActivationLink = null; + this.linkActivationValidation = undefined; this.linkActivationOrigin = null; if (this.canvas.hasPointerCapture(event.pointerId)) { this.canvas.releasePointerCapture(event.pointerId); } if (event.type !== "pointercancel") { - if (link && isSameTerminalLink(link, this.linkAt(event.clientX, event.clientY))) { - this.options.onLinkActivate(link.text, event); + if (link && isSameTerminalLink(link, this.linkAt(event.clientX, event.clientY, true))) { + this.activateLink(link, event, validation); } } return; @@ -1988,10 +2062,12 @@ export class GhosttyTerminalSurface { this.metrics.height, anchorBottom, ); + const geometryChanged = canvasConfiguration !== null || nextOriginY !== this.originY; if (nextOriginY !== this.originY) { this.originY = nextOriginY; this.forceFullRender = true; } + this.updateLinkRowStability(geometryChanged); this.refreshHoveredLink(); renderGhosttySnapshot({ context: this.context, @@ -2080,9 +2156,51 @@ export class GhosttyTerminalSurface { }; } - private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null { + private validateLink(text: string): Promise | undefined { + if (this.options.canActivateLink?.(text) !== false) return; + const validation = this.options.validateLink?.(text); + // A drag can abandon the press before activateLink subscribes to its result. + void validation?.catch(() => {}); + return validation; + } + + private activateLink( + link: TerminalLinkWithRange, + event: MouseEvent, + validation = this.validateLink(link.text), + ): void { + if (!validation) { + this.options.onLinkActivate(link.text, event); + return; + } + const requestId = this.linkActivationRequestId; + void validation.then( + (valid) => { + if ( + this.disposed || + !this.visible || + requestId !== this.linkActivationRequestId || + !isSameTerminalLink(link, this.findLinkAt(event.clientX, event.clientY)) + ) + return; + if (valid) this.options.onLinkActivate(link.text, event); + else this.clearSelection(); + }, + () => {}, + ); + } + + private linkAt( + clientX: number, + clientY: number, + allowValidation = false, + ): TerminalLinkWithRange | null { const link = this.findLinkAt(clientX, clientY); - return link && this.options.canActivateLink?.(link.text) !== false ? link : null; + return link && + ((allowValidation && this.options.validateLink) || + this.options.canActivateLink?.(link.text) !== false) + ? link + : null; } private findLinkAt(clientX: number, clientY: number): TerminalLinkWithRange | null { diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts new file mode 100644 index 0000000000000..5b5e11d812027 --- /dev/null +++ b/apps/web/src/timestampFormat.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatDayAwareTimestamp, formatUpcomingTimestamp } from "./timestampFormat"; + +describe("timestamps in an explicit time zone", () => { + // 23:00 UTC on July 1 is 7 PM on July 1 in New York, whatever the host zone. + const now = Date.parse("2026-07-01T23:00:00.000Z"); + const zone = "America/New_York"; + + it("picks relative days from the calendar in that zone", () => { + expect(formatUpcomingTimestamp("2026-07-02T00:30:00.000Z", "12-hour", now, zone)).not.toMatch( + /tomorrow/, + ); + expect(formatUpcomingTimestamp("2026-07-02T05:30:00.000Z", "12-hour", now, zone)).toMatch( + /^tomorrow at /, + ); + expect(formatDayAwareTimestamp("2026-07-01T03:30:00.000Z", "12-hour", now, zone)).toMatch( + /^yesterday at /, + ); + }); +}); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 831e58a26b5a1..b9a830dd08283 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -76,17 +76,18 @@ const timestampFormatterCache = new Map(); function getTimestampFormatter( timestampFormat: TimestampFormat, includeSeconds: boolean, + timeZone?: string, ): Intl.DateTimeFormat { - const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`; + const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}:${timeZone ?? ""}`; const cachedFormatter = timestampFormatterCache.get(cacheKey); if (cachedFormatter) { return cachedFormatter; } - const formatter = new Intl.DateTimeFormat( - timestampLocale, - getTimestampFormatOptions(timestampFormat, includeSeconds), - ); + const formatter = new Intl.DateTimeFormat(timestampLocale, { + ...getTimestampFormatOptions(timestampFormat, includeSeconds), + ...(timeZone ? { timeZone } : {}), + }); timestampFormatterCache.set(cacheKey, formatter); return formatter; } @@ -137,58 +138,88 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp return getTimestampFormatter(timestampFormat, false).format(date); } -const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, { - month: "numeric", - day: "numeric", -}); -const numericDateWithYearFormatter = new Intl.DateTimeFormat(timestampLocale, { - month: "numeric", - day: "numeric", +const numericDateOptions: Intl.DateTimeFormatOptions = { month: "numeric", day: "numeric" }; +const numericDateWithYearOptions: Intl.DateTimeFormatOptions = { + ...numericDateOptions, year: "numeric", -}); +}; +const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, numericDateOptions); +const numericDateWithYearFormatter = new Intl.DateTimeFormat( + timestampLocale, + numericDateWithYearOptions, +); + +interface CalendarDay { + year: number; + /** Days since the epoch, so differences are whole days across DST shifts. */ + index: number; +} + +function calendarDay(date: Date, timeZone: string | undefined): CalendarDay { + if (!timeZone) { + const year = date.getFullYear(); + return { year, index: Date.UTC(year, date.getMonth(), date.getDate()) / 86_400_000 }; + } + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "numeric", + day: "numeric", + }).formatToParts(date); + const part = (type: Intl.DateTimeFormatPartTypes) => + Number(parts.find((entry) => entry.type === type)?.value); + const year = part("year"); + return { year, index: Date.UTC(year, part("month") - 1, part("day")) / 86_400_000 }; +} + +function formatNumericDate(date: Date, sameYear: boolean, timeZone: string | undefined): string { + if (!timeZone) { + return (sameYear ? numericDateFormatter : numericDateWithYearFormatter).format(date); + } + return new Intl.DateTimeFormat(timestampLocale, { + ...(sameYear ? numericDateOptions : numericDateWithYearOptions), + timeZone, + }).format(date); +} export function formatDayAwareTimestamp( isoDate: string, timestampFormat: TimestampFormat, nowMs: number = Date.now(), + timeZone?: string, ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = getTimestampFormatter(timestampFormat, false).format(date); + const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date); - const now = new Date(nowMs); - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const startOfMessageDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); - // Round so DST-shifted 23/25 hour days still count as whole days. - const dayDiff = Math.round((startOfToday - startOfMessageDay) / 86_400_000); + const today = calendarDay(new Date(nowMs), timeZone); + const messageDay = calendarDay(date, timeZone); + const dayDiff = today.index - messageDay.index; if (dayDiff <= 0) return time; if (dayDiff === 1) return `yesterday at ${time}`; - const dateFormatter = - date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter; - return `${dateFormatter.format(date)} ${time}`; + return `${formatNumericDate(date, messageDay.year === today.year, timeZone)} ${time}`; } +/** `timeZone` overrides the zone the formatters cached when they were built. */ export function formatUpcomingTimestamp( isoDate: string, timestampFormat: TimestampFormat, nowMs: number = Date.now(), + timeZone?: string, ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = getTimestampFormatter(timestampFormat, false).format(date); + const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date); - const now = new Date(nowMs); - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const startOfTargetDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); - const dayDiff = Math.round((startOfTargetDay - startOfToday) / 86_400_000); + const today = calendarDay(new Date(nowMs), timeZone); + const targetDay = calendarDay(date, timeZone); + const dayDiff = targetDay.index - today.index; - if (dayDiff < 0) return formatDayAwareTimestamp(isoDate, timestampFormat, nowMs); + if (dayDiff < 0) return formatDayAwareTimestamp(isoDate, timestampFormat, nowMs, timeZone); if (dayDiff === 0) return time; if (dayDiff === 1) return `tomorrow at ${time}`; - const dateFormatter = - date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter; - return `${dateFormatter.format(date)} ${time}`; + return `${formatNumericDate(date, targetDay.year === today.year, timeZone)} ${time}`; } type RelativeTimeParts = { value: string; suffix: string | null }; diff --git a/docs/internals/coder-only.md b/docs/internals/coder-only.md index ab63618738d7e..5dd17c69e1b8e 100644 --- a/docs/internals/coder-only.md +++ b/docs/internals/coder-only.md @@ -465,7 +465,10 @@ checks that every name here still exists and that every modified upstream file i newest requested turn, and fail before encoding when over the stdio frame; a transcript over the limit fails with `OrchestrationThreadTranscriptTooLargeError`. - `ws.ts`: `withPullRequestViewer` is an identity (GitLab uses the workspace's `glab` login); the - on-demand workspace-snapshot stripping (`workspaceSnapshotsOnDemand`) has no usage-limit commands. + on-demand workspace-snapshot stripping (`workspaceSnapshotsOnDemand`) has no usage-limit commands; + settings redaction gets a constant `settingsClient` (`secretReferences: true`), because the helper + has no WebSocket query to read upstream's `clientCapabilities` from (#27760), and its one client + is the fork's web build. - `persistence/DatabaseStorage.ts` is built beside `StorageCleanup` once startup completes; its `t3 doctor` file-size status is cut (no CLI in the helper). - `coderServer.ts` runs upstream's idle Claude compaction worker (`layerIdleCompactionWorker`) in @@ -518,7 +521,10 @@ checks that every name here still exists and that every modified upstream file i `Query` methods sent as control requests: `interrupt`, `stopTask`, `setModel`, `setPermissionMode`, `setMaxThinkingTokens`, `applyFlagSettings`, `getContextUsage`, `getSettings`, `getUsage` (with `skipBehaviors`), `setMcpServers` (no deadline), `backgroundTasks` - (resolves to `backgrounded`, true when absent), `initializationResult`, and `close`. + (resolves to `backgrounded`, true when absent), `mcpServerStatus`, `readMcpResource`, + `initializationResult`, and `close`; `initialize` carries the session `title` (#28910). MCP Apps + (#29803) reach the SDK's private `request` for `mcp_call` and `list_permission_rules`, which + `ClaudeCli.ts` sends as raw control requests (`RAW_CONTROL_REQUEST_SUBTYPES`, nothing else). `can_use_tool` passes `matchedAskRule` and `suppressAlwaysAllowRule` to `canUseTool`. - `Drivers/ClaudeAgentSdk.ts` provides SDK-typed `query` and `getSubagentMessages` over the CLI, typed as the full SDK `Query` so a new method fails typecheck until mapped or rejected; it answers @@ -559,7 +565,8 @@ checks that every name here still exists and that every modified upstream file i `CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES`); `CodexDeveloperInstructions.ts` and `ClaudeAdapterV2.ts` describe the bridge command, and Claude pre-approves it as a Bash prefix (read-only tools in a read-only sandbox). `CodexAdapterV2` does not rewrite failures through - `CodexManagedErrors`. + `CodexManagedErrors`, so a usage-limit failure's reset time is `codexUsageLimitResetAt` alone + (#25341's sharing-cap case is managed Codex). - `PastedImageAttachments.ts` is the only provider image reader: `localImage` paths for Codex, base64 for Claude; Read-tool image views are limited to PNG, JPEG, and WebP; Codex branch-name and title generation read images through it. @@ -608,7 +615,11 @@ checks that every name here still exists and that every modified upstream file i `managementFailure` reasons for the cut actions; neither is read here. - provider-acp's `adapter.ts` gives ACP sessions no MCP server and sends `t3ToolBridgeInstructions` with the first prompt through provider-core's `t3AcpPromptWithInstructions`; `mcpOverAcpBridge.ts` - and `clientTerminals.ts` stay dormant (cutting them would add about 34 seams). HTTP MCP servers + and `clientTerminals.ts` stay dormant (cutting them would add about 34 seams), as do the flavor + hooks Kiro (#14693) added to `adapter.ts` and `AcpSessionRuntime.ts` (steering, fork from turn, + `compactSession`, `cancelAfterPromptStarts`, `ignoreSessionUpdate`, `configOptionChanges`) and + `runtimeModel.ts`'s Kiro tool-name parsing; Kiro's package, registration, fixtures, and guide are + cut. HTTP MCP servers (#26968) are cut: the adapter's flavors have no `mcpOverHttp` (only Copilot's uncarried flavor set it) and `AcpSessionRuntime.ts` takes no `httpMcpServers`. `scripts/acp-mock-agent.ts` keeps `T3_ACP_COMMAND_ADVERTISEMENT_DELAY_MS` for the adapter test. @@ -636,7 +647,7 @@ checks that every name here still exists and that every modified upstream file i `ProviderSessionManager.ts` no session or turn metrics, `ScheduledTaskService.ts` no webhook metrics, `SecretRequests.ts` no consumed-ref count, `EventSink.ts` no run outcome metrics (`RunMetrics.ts`), and `RunExecutionService.ts` no `PreviewAgentActivity`. Provider token - analytics (#29001) is cut: `telemetry/ProviderUsageAnalytics.ts` keeps only its report types and + analytics (#29001) and its service tiers (#29348) are cut: `telemetry/ProviderUsageAnalytics.ts` keeps only its report types and the no-op `ProviderTurnAnalytics` reference that `ProviderEventIngestor.ts`, `ProviderTurnStartService.ts`, and `RunExecutionService.ts` call, with no analytics layer or request ledger; the token usage counts those turns carry are upstream's. `EventNdjsonLogger` and @@ -698,7 +709,8 @@ checks that every name here still exists and that every modified upstream file i directories on `PATH`. - client-runtime adds `TerminalBufferCache`, a bounded memory cache that seeds a reattach and supplies its `afterSequence` and `afterEpoch`; a resumed attach of a closed terminal gets - upstream's `closed` event. `ThreadTerminalDrawer` never opens terminal links. + upstream's `closed` event. `ThreadTerminalDrawer` never opens terminal links, so it has no + file-metadata link check (#30526: `validateLink`, `onLinkHover`). ### Source control and merge requests @@ -720,7 +732,7 @@ checks that every name here still exists and that every modified upstream file i (`server/GitLabMediaFetch.ts`) reads with `glab api` in the minted `cwd` (no `--hostname`), buffering at most `GITLAB_MEDIA_MAX_BYTES`. The package also exports `server/GitLabCli`, which `ws.projects.test.ts` stubs. -- `GitLabCli.ts` fixes bugs still in upstream (checked against upstream `main` 47f2c8d29e): merge +- `GitLabCli.ts` fixes bugs still in upstream (checked against upstream `main` 337c5858c3): merge requests between projects post to the source project with a numeric `target_project_id`; merge request URLs reach `glab` as `--repo … -- `. `glab auth status` lines with a token label or `glpat-` token are never surfaced; clones accept only GitLab URLs without credentials, @@ -754,8 +766,8 @@ checks that every name here still exists and that every modified upstream file i - `SettingsPanels.tsx`'s Diffs page defines the "Open merge requests in T3 Coder" row, because upstream's Integrations page is not carried. - `pullRequestList.logic.ts`'s filter haystack also matches `!123`. -- `pullRequestEditing.ts` has no `canEditPullRequestMaintainerEdits`; GitHub maintainer edits - (#25588) have no web control. +- `pullRequestEditing.ts` has no `canEditPullRequestMaintainerEdits`, and keeps + `maintainerEditsChoiceInForce` unexported; GitHub maintainer edits (#25588) have no web control. - `GitManager.ts`: merge request wording in its ref fetch and uncommitted-changes errors. ### Git layer @@ -764,7 +776,7 @@ checks that every name here still exists and that every modified upstream file i thread's worktree folder with its branch, under the 5-minute worktree remove deadline) and `refStatusLocal` (never refreshes the real index); review file expansion allows up to `MAX_REVIEW_DIFF_FILE_BYTES` and fails with `ReviewDiffFileTooLargeError`, rejecting malformed - UTF-8; a top-level review `sourceKind` returns only that source; worktree creation reads `t3.json` + UTF-8; worktree creation reads `t3.json` through the bounded `readProjectConfig`; checkpoint commits use the "T3 Coder" identity. - `vcs/VcsProcess.ts` records no Git command metrics. - `git/GitWorkflowService.ts` adds `moveWorktree` and `localRefStatus` pass-throughs. @@ -792,7 +804,8 @@ checks that every name here still exists and that every modified upstream file i client-runtime's `connection/driver.ts`, `connection/index.ts`, `connection/wakeups.ts`, and `platform/persistence.ts` drop multi-route checks, credential and profile stores, saved targets, onboarding, GitHub routing, host updates, and the mobile `network-changed` wakeup. - `connection/supervisor.ts` gives the route no kind, so probe timeouts treat it as remote (#25824); + `connection/supervisor.ts` gives the route no kind, so probe timeouts treat it as remote (#25824), + and has no primary-target offline exemption (#29347) or learned-route lease (`ConnectedLease`); `checkHost` never answers (the gateway answers either way); `connectedLeaseEnd` ends a lease only on a disconnect. `connection/registry.ts` starts every registered supervisor desired, applies every reconcile (no #26511 skip), carries no cached unsupported-host rejection, and has no @@ -893,8 +906,8 @@ checks that every name here still exists and that every modified upstream file i and closes, keeping its commit message; `projectFilesQueryState.ts` has no `isFile`. - Diff panel (`DiffPanel.tsx`): expanded context loads through the chunked review RPCs (`createChunkedGitDiffFileContentsLoader`), a file over the limit is listed in - `DiffFileExpansionErrorNotice`, branch previews ask for one top-level `sourceKind`, and a file - title opens the Files surface with no local-editor fallback. + `DiffFileExpansionErrorNotice`, and a file title opens the Files surface with no local-editor + fallback. - `patches/@pierre%2Fdiffs@1.6.1.patch` adds a guard that ignores loaded contents unless the diff is still the partial diff that asked, and lets a contents loader return `null` (`FileDiffContentsLoader` and `LoadedPartialDiffContents` types), which 1.6.1 still lacks. @@ -1013,11 +1026,15 @@ checks that every name here still exists and that every modified upstream file i `overrides` pin only the patched packages the fork carries plus Tailwind and lightningcss; `@effect/platform-node>undici` is removed; the gateway pins its own `ws`/`@types/ws`; `ignoredOptionalDependencies` keeps the Agent SDK's Claude binaries and `@napi-rs/canvas` off the - laptop. -- TypeScript is Microsoft's TypeScript 7 (`typescript: 7.0.2`), not the `@tsc-rs/typescript` - drop-in whose unsigned Windows `tsc.exe` endpoint security may block; the fork omits its build - approval, peer rule, and `.pnpmfile.cjs` hook, and never ran `@effect/tsgo`. Root `engines.node` - stays `^24.13.1`. + laptop. Peers are not auto-installed, so with upstream's `strictPeerDependencies` + `peerDependencyRules.ignoreMissing` also lists `@effect/platform-node`'s unused `redis` peer. +- Typechecks run upstream's `@effect/tsgo` (`effect-tsgo tsrs`, #31028), whose platform + packages ship native binaries. The root `typecheck:*` scripts are the fork's and run the same + command per app; `typescript-legacy` sits in the root devDependencies, because knip's + `scripts/knip-schemas.ts` preprocessor needs the JavaScript compiler API (upstream keeps it in the + uncarried `scripts/package.json`). `.vscode/` is not carried, including its `tsdk/tsgo` + launcher, a tracked shell script (see "No tracked shell scripts"). Root `engines.node` stays + `^24.13.1`. - Package manifests (`package.json`) and `tsconfig.json` files drop dependencies, scripts, exports, and includes for cut surfaces and uncarried tooling; `routeTree.gen.ts` is regenerated without cut routes; `third-party-licenses.config.json` lists no desktop bundle or desktop-only assets; @@ -1041,6 +1058,10 @@ checks that every name here still exists and that every modified upstream file i and `refs/merge-requests//head`; tests of uncarried hosts' behaviour are cut; `GitManager.test.ts` is upstream's fake-provider harness with a GitLab default kind; `PullRequestSyncReactor.test.ts` closes with `glab mr merge`/`glab mr close`; + `RuntimePolicy.test.ts` drops upstream's Antigravity Auto case (#23698); the fork-only + `AcpRegistryAdapterV2.test.ts` bridge case turns on outgoing protocol logging, which the adapter + no longer enables by default (#29100); client-runtime's `fileMetadata`, `projectCommands`, and + `threadCommands` tests build the gateway target in place of upstream's bearer target (#31028); `ClaudeAdapterV2.test.ts` drives the MCP control-channel tests through a fake `claude`; `ClaudeAgentSdkQueryRunner.test.ts` stubs the CLI transport's `query`; `ProviderSessionManager.test.ts`'s parallel shutdown test waits in real time; @@ -1097,19 +1118,28 @@ them, the file is named here. (`contracts/ipc.ts`, `resolveDesktopTheme`), desktop update notices or pills, window controls, `DesktopWebLinkCoordinator`, desktop folder path lookup (`folderDrop.ts`), back/forward swipe navigation (`HistorySwipeNavigation.tsx`, `navigationHistory.ts`), mobile app links - (`MobileAppSettingsRow`), hosted app, or prompt links (`components/promptLinks/`, the "Copy prompt - link" actions, the automation row's link). `Icons.tsx` drops `OnePasswordIcon`. + (`MobileAppSettingsRow`, #29815's `.well-known` files), hosted app, or prompt links + (`components/promptLinks/`, the "Copy prompt link" actions, the automation row's link). Copied + thread links (#26290) use the gateway's own origin: `ChatView.tsx`, `CommandPalette.tsx`, + `Sidebar.tsx`, `useThreadActionMenu.ts`, and `MarkdownThreadLink.tsx` pass + `window.location.origin` where upstream calls `configuredHostedAppUrl`, and shared + `threadLinks.ts` declares `DEFAULT_HOSTED_APP_URL` (upstream's `connectAuth.ts` is cut), so + upstream's hosted links still parse. Cloud link state (`cloud/cloudLinkState.ts`) is cut from + `ScheduledTasksSettings.tsx` and `useAvailableSettingsSearchItems.ts`. `Icons.tsx` drops `OnePasswordIcon`. `media/mediaContent.ts`'s `fetchMediaBlob` has no desktop native read after a failed fetch (#29079). - **Auth, pairing, and relay.** No HTTP/WebSocket listener, session scopes, pairing, DPoP, relay, or connection catalog; `settings/AddEnvironmentDialog.tsx` is cut. The server's `auth/` keeps - `ServerSecretStore`, its utils, and `serverInternalPaths.ts` (#28041), which the carried file - readers import. With no session scopes, + `ServerSecretStore`, its utils (only the base64url helpers), `serverInternalPaths.ts` (#28041), + which the carried file readers import, and `PayloadSigner.ts` (#30523), which the carried + `assets/AssetSigner.ts` signs asset and upload URLs with; `server.ts`'s `layerServerSettings` + provides `AssetSigner`. With no session scopes, scheduled task lists show every webhook URL. - **Browser preview and devices.** No in-app browser, preview panel, mini-player, preview MCP toolkits, device hosts, or agent device shim (port discovery stays: `preview/PortScanner.ts`); provider-core's device instructions never reach an agent. Without browser preview, `composerDraftStore.ts` keeps no empty draft alive for an open - page, and `mergeDirectPreviewAnnotation` is not carried. Deleting a thread clears its + page, `mergeDirectPreviewAnnotation` is not carried, and `attachmentStore.ts` deletes no agent + preview recordings (#29853). Deleting a thread clears its right-panel and diff-panel state (`useThreadActions.ts`'s `clearDeletedThreadPanelState`, #28386) but has no preview or mini-player state to clear. - **Telemetry, usage, and diagnostics.** No T3 telemetry, analytics, client-origin attribution, @@ -1137,12 +1167,15 @@ them, the file is named here. Antigravity's native child work (#28629: `NativeChildWorkQuery.ts`, `NativeChildWorkDialog.tsx`, client-runtime `state/orchestration.ts`'s `nativeChildWork` atom, and the `ChatView.tsx` slot that rendered it). Codex - feedback upload (client-runtime `threadCommands.ts` has no `uploadFeedback`) and SnapShot sources - are cut. + feedback upload (client-runtime `threadCommands.ts` has no `uploadFeedback`), the ChatGPT + plan-sharing notice (#25341: `UsageLimitRecoveryBanner.tsx`, `ThreadErrorBanner.tsx`'s workspace + limit), and SnapShot sources are cut. - **Hosted source control other than GitLab.** GitHub, Azure DevOps, Bitbucket, Forgejo, and GitCafe packages, host settings, tokens, accounts, routing, forge app sign-in (#26245, `SourceControlSignIn`), app-access notices (`sourceControlAppAccessToast.ts`), and the GitHub star - prompt (#27832, `GitHubStarPrompt`, `checkStarred`, `useComposerSend`'s `recordPromptSent`). Web + prompt (#27832, `GitHubStarPrompt`, `checkStarred`, `useComposerSend`'s `recordPromptSent`; the + idle prompt and confetti #29922 and #29987, so `sourceControl.star` is not served and + source-control-core's driver has no `starRepository`). Web `SourceControlAppAccessNotice.tsx`, `T3AppIcon.tsx`, and `settings/SourceControlAppSignInSettings.tsx` are cut, and client-runtime `state/sourceControl.ts` and `sourceControlClients.ts` have no app-access or sign-in atoms or `appSignInTargetForRemote`. @@ -1166,7 +1199,8 @@ them, the file is named here. - **Triage** (#22753). `apps/server/src/triage/**`, `server.prepareTriage`, the palette entry (`CommandPalette.tsx`), the draft hero (`DraftHeroHeadline.tsx`), `useHandleNewThread`'s `prompt` option, client-runtime's `prepareTriage`, and `docs/user/triage.md`. -- **Secret providers** (#25709, #27044, #27115, #27746). The secrets settings, 1Password, the +- **Secret providers** (#25709, #27044, #27115, #27746, and the host-token, usage-limit-key, and + EAS readers #27756, #27760, #27763). The secrets settings, 1Password, the `T3CODE_FEATURE_SECRETS` flag, the `/settings/secrets` route, `@t3tools/secret-provider-core`, and the server's `secrets/SecretResolver.ts`, `secretProviderDefinitions.ts`, and `secrets/settingsSecretValue.ts`. `secrets/coderSecretResolver.ts` stands in for the resolver in @@ -1194,7 +1228,10 @@ them, the file is named here. `AddProviderInstanceDialog.tsx`, `LabsSettingsPanel.tsx`, `ProjectDefaultsSettings.tsx`, `ProviderInstanceCard.tsx`, `ProviderSettingsForm.tsx`, `ProviderSettingsPanel.tsx`, `ScheduledTasksSettings.tsx`, `SettingsPanels.tsx`, `StorageSettings.tsx`, and - `settingsLayout.tsx`; and `docs/user/install.md` and `permission-modes.md`. Later upstream code + `settingsLayout.tsx`; and `docs/user/install.md` and `permission-modes.md`. The later Group + Policy template, Kiro coverage, and update mirror (#29862, #29915, #29911) are cut with it, so + `runtimeModeConfig.ts`'s `resolveRuntimeModeOptions` takes no policy (`ChatComposer.tsx`, + `ComposerContextPermissions.tsx`). Later upstream code that builds on #27197's non-policy refactors takes upstream's form (`CodexDriver.ts` passes `HostProcess.Environment` to `mergeProviderInstanceEnvironment`). - **Add to another machine** (#22180, #22254). It copies ignored files such as `.env` between diff --git a/docs/internals/sync.md b/docs/internals/sync.md index d8e01e10d536a..e51780dcbd7bc 100644 --- a/docs/internals/sync.md +++ b/docs/internals/sync.md @@ -377,12 +377,12 @@ result. Read every line it prints; it changes nothing. Its checks (`--only ` links name no environment: clients resolve them in the message's environment, then by a unique match in another enabled environment. An outside OAuth client, one that is not a linked environment, cannot render `t3-thread://` links, so it -gets a web URL on the origin it reached, `//`, -for threads in this environment. +gets the link Copy thread copies, `https://app.t3.codes/threads//` +on the server's `T3CODE_HOSTED_APP_URL`, for threads in this environment. The +hosted app offers the desktop app, the mobile app, or itself. List and read results report `snoozed` and `snoozedUntil`, and `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the @@ -455,6 +465,22 @@ Without `runId`, it selects the newest interruptible run. A terminal run is returned unchanged, and a thread with no active provider turn returns `no_active_run`. +### Scheduled tasks + +`schedule_task`, `list_scheduled_tasks`, and `delete_scheduled_task` manage +recurring work in the app scheduler; `schedule_task` with a `scheduledTaskId` +updates an existing task. A schedule +runs its prompt on an interval, at a fixed time in the server's local time +zone, or on each qualifying webhook request, even when no turn is active. +Without a signature, anyone with the webhook URL can trigger a run. +Signature checks, filters, and dedupe can skip a webhook request. +`bindToCurrentThread` defaults to `true` only when the caller has a thread in +the task's project; otherwise it defaults to `false`. Bound runs post into +that thread; unbound runs each launch a fresh thread in the task's project. +Explicit `bindToCurrentThread: true` without a caller thread in that project +is rejected. Pausing uses `schedule_task` with `scheduledTaskId` and `enabled: false`; +deletion is permanent. + ## Delegated Task Lifecycle The MCP server is a command ingress into V2. It does not call provider adapters @@ -490,8 +516,12 @@ Users rarely watch child threads, so a child question would otherwise go unanswered. When a child opens a pending `user_input` request, the parent gets a notification message telling it to answer with the pending request tools or ask the user. The message says whether the child is blocked or, for a Codex -async question, still working. If the question is answered or dismissed before -the notification starts a turn, that queued turn is cancelled. Approvals are not +async question, still working. If the parent's turn is running and its provider +can steer without interrupting tools, the notification steers into that turn, +like a background task's completion. A queued notice would wait behind a +long-running orchestrator turn that may never end. Otherwise it queues. If the +question is answered or dismissed before a queued notification starts a turn, +that queued turn is cancelled. Approvals are not routed, because those tools cannot answer them. A parent still blocked in a `delegate_task` wait gets the blocking question through that wait instead. diff --git a/docs/user/composer.md b/docs/user/composer.md index d6c3e438599a7..80b81c5b5761b 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -279,7 +279,7 @@ merge request in T3 Coder. The captured details remain part of the message after Another thread can be context too. Type `@` followed by part of its title to pick one from the same workspace, or drag a thread out of the sidebar and drop it on the composer; a multi-selection drops -together. **Copy thread** puts a thread on the clipboard that pastes into a composer as its chip. +together. **Copy thread** copies a link to the thread that pastes into a composer as its chip. The chip shows the thread's current title and opens it when selected. Your prompt only carries a reference: the agent reads the thread's history on demand, so attaching a long thread costs nothing until the agent looks. Attaching a thread does not change it, and the agent cannot diff --git a/docs/user/html-renders.md b/docs/user/html-renders.md index 278005fe5845b..196aa2d009858 100644 --- a/docs/user/html-renders.md +++ b/docs/user/html-renders.md @@ -22,9 +22,9 @@ agent chose and shrinks to fit a shorter page; a longer page scrolls inside it. Some MCP servers return an interactive app with their tool results, following the [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) standard. When an agent calls one of -those tools, the app appears in the thread in place of the tool call. Codex supports this today, for -any MCP server configured for it in the workspace; other providers show these calls as ordinary -tool calls. +those tools, the app appears in the thread in place of the tool call. Codex and Claude support this +today, for any MCP server configured for them in the workspace. Claude needs Claude Code 2.1.301 or +newer; older versions and other providers show these calls as ordinary tool calls. Apps follow your theme. An app can call tools on its own server and post a message to the thread; T3 Coder asks first unless the server marks the tool as read-only, and it asks before every diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 7c87f7535b960..75b003c31cc1f 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -84,8 +84,10 @@ Join modifiers and a key with `+`, such as `mod+shift+d` or `ctrl+l`. ## When conditions -Available context keys are `terminalFocus`, `terminalOpen`, `modelPickerOpen`, -`composerFocus`, `draftThreadRoute`, `turnRunning`, `editableFocus`, `isWeb`, and `isDesktop`. +Available context keys are `terminalFocus`, `terminalOpen`, `rightPanelOpen`, +`modelPickerOpen`, `composerFocus`, `draftThreadRoute`, `turnRunning`, `editableFocus`, `isWeb`, +and `isDesktop`. `rightPanelOpen` applies only to `rightPanel.new` and is true while the right +panel is open; the default `mod+t` uses it to open the panel's new-tab menu. `draftThreadRoute` is true on a draft thread, including a restored draft, until it is first sent. `editableFocus` is true while a text field, the composer, or another editor has the keyboard. `isWeb` is always true and `isDesktop` always false, because T3 Coder runs in a diff --git a/docs/user/keyboard-focus.md b/docs/user/keyboard-focus.md index 100d32ba46cb4..976ec1a2c8137 100644 --- a/docs/user/keyboard-focus.md +++ b/docs/user/keyboard-focus.md @@ -26,9 +26,9 @@ can be remapped in Settings > Keybindings. ## Browse threads -Tab enters the sidebar once. Up and Down move between threads without opening them; Enter -opens the focused thread or toggles a group. Right and Left expand and collapse groups. -Tab moves from the focused thread to its buttons. `F2` renames the focused thread. +Tab moves through each thread and its buttons. Up and Down also move between threads +without opening them; Enter opens the focused thread or toggles a group. Right and Left +expand and collapse groups. `F2` renames the focused thread. Press `Shift+F10` or the Context Menu key to open the focused thread's menu. Use the arrow keys to move through it, Enter to choose, and Escape to close it. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index defe46aee5402..d2047d3b88c5d 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -114,6 +114,7 @@ keep a thread active; automatic settlement can proceed once every linked review to the workspace's settlement settings. GitHub-native stack merge and rebase actions are not supported. Press Command-Enter (Control-Enter on Windows) to save an edited review description or comment. +Where you can edit a description, you can also tick its checkboxes from the Summary tab. Recent review summaries are cached in the workspace across helper restarts. Review contents and linked-review panel state remain in browser memory while displayed. @@ -172,6 +173,11 @@ messages wait until cloning succeeds. Removing a failed project is also availabl When GitLab refuses the workspace's SSH key, T3 Coder clones over HTTPS with Git's credentials for that host instead, and the project's remote uses the HTTPS URL. +To lay clones out like [ghq](https://github.com/x-motemen/ghq), turn on **Clone into host folders** +under **Settings → Threads**. Clones then default to `//` under **Add project +starts in**, so set that to `~/ghq` to share ghq's folders. You can still change the destination +before cloning. + Mark files **Viewed** in a merge request's Code tab to track your review. Marks are saved in the Coder workspace and shared by connected clients. A later push marks changed files as stale so you can review them again. GitLab authentication continues to use the workspace's `glab` CLI. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index bce036e2a0576..c8e11a7586b14 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -5,8 +5,11 @@ need a separate branch and working directory. ## Start a thread -A new thread keeps the current project and carries your model -and mode selections, unless the destination project has its own model default. +**New thread** opens the project picker when you have multiple +projects in the default or project-grouped sidebar. The legacy sidebar always +starts in the current project. **Shift-click** to start in +the current project, or use a project's own new-thread action. A new thread carries +your model and mode selections, unless the destination project has its own model default. Its branch and workspace mode come from your configured defaults. To continue in an existing worktree, use **New thread in this worktree** from the branch toolbar. @@ -202,9 +205,10 @@ one word of three or more letters, or two for Chinese, Japanese, and Korean. Use **Settings → Keybindings** to find or customize shortcuts for searching files and copying a thread reference. A copied reference uses the thread's merge request -link when available, otherwise the thread itself, which pastes into a composer as -a thread chip. To copy the thread even when it has a merge request, choose **Copy → Thread** -from its context menu. **Thread ID** copies the bare ID for scripts. See +link when available, otherwise a link to the thread, which opens it in T3 Coder on +this computer and pastes into a composer as a thread chip. To copy the thread even +when it has a merge request, choose **Copy → Thread** from its context menu. +**Thread ID** copies the bare ID for scripts. See [keybindings](./keybindings.md) for custom configuration. ## Inspect agent work diff --git a/knip.jsonc b/knip.jsonc index 16a4f88130084..f199370983e89 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -9,6 +9,7 @@ // longer reports unused exports. "ignoreIssues": { "apps/server/src/attachmentStore.ts": ["exports", "nsExports"], + "apps/server/src/auth/PayloadSigner.ts": ["exports", "nsExports"], "apps/server/src/auth/ServerSecretStore.ts": ["exports", "nsExports"], "apps/server/src/environmentTheme.ts": ["exports", "nsExports"], "apps/server/src/git/GitManager.ts": ["exports", "nsExports"], @@ -126,6 +127,7 @@ "packages/shared/src/orchestrationV2PendingBackgroundWork.ts": ["exports", "nsExports"], "packages/shared/src/serverSettings.ts": ["exports", "nsExports"], "packages/shared/src/shell.ts": ["exports", "nsExports"], + "packages/shared/src/socketFrameParts.ts": ["exports", "nsExports"], "packages/shared/src/sourceControl.ts": ["exports", "nsExports"], "packages/shared/src/t3McpToolPresentation.ts": ["exports", "nsExports"], "packages/shared/src/themePalettes.ts": ["exports", "nsExports"], diff --git a/package.json b/package.json index bc598fbe05e76..d04f3c29026b5 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,10 @@ "coder:pr-smoke": "pnpm --filter @t3tools/web build && node scripts/coder-pr-smoke.mjs", "start": "pnpm build && node apps/coder-gateway/src/bin.ts --open-browser", "generate:sbom": "pnpm sbom --sbom-format cyclonedx --sbom-spec-version 1.6 --sbom-type application --lockfile-only --prod --out docs/sbom.cdx.json", - "typecheck:gateway": "tsc --noEmit -p apps/coder-gateway/tsconfig.json", - "typecheck:helper": "tsc --noEmit -p apps/coder-helper/tsconfig.json", - "typecheck:server": "tsc --noEmit -p apps/server/tsconfig.json", - "typecheck:web": "tsc --noEmit -p apps/web/tsconfig.json", + "typecheck:gateway": "effect-tsgo tsrs --noEmit -p apps/coder-gateway/tsconfig.json", + "typecheck:helper": "effect-tsgo tsrs --noEmit -p apps/coder-helper/tsconfig.json", + "typecheck:server": "effect-tsgo tsrs --noEmit -p apps/server/tsconfig.json", + "typecheck:web": "effect-tsgo tsrs --noEmit -p apps/web/tsconfig.json", "test:contracts": "pnpm --filter @t3tools/contracts test", "test:server": "vp test run --config vite.coder.config.ts", "test:coder": "node --test apps/coder-gateway/src/*.test.ts apps/coder-helper/src/*.test.ts packages/coder-cli/src/*.test.ts && pnpm --filter effect-codex-app-server test && pnpm test:contracts && pnpm test:server && pnpm test:providers && pnpm test:source-control && pnpm test:client-runtime && pnpm test:shared:coder && pnpm test:web:coder && pnpm test:knip", @@ -34,9 +34,9 @@ "sync:resolve-deleted": "node scripts/sync-resolve-deleted.mjs" }, "devDependencies": { + "@effect/tsgo": "0.67.1", "@types/node": "catalog:", "knip": "6.34.0", - "typescript": "catalog:", "typescript-legacy": "npm:typescript@~6.0.3", "vite-plus": "catalog:" }, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 45f7d8e5b4762..535905c738856 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -294,6 +294,10 @@ "types": "./src/state/composerDispatch.ts", "default": "./src/state/composerDispatch.ts" }, + "./state/context-window": { + "types": "./src/state/contextWindow.ts", + "default": "./src/state/contextWindow.ts" + }, "./state/thread-subagents": { "types": "./src/state/threadSubagents.ts", "default": "./src/state/threadSubagents.ts" @@ -393,10 +397,14 @@ "./provider-clients": { "types": "./src/providerClients.ts", "default": "./src/providerClients.ts" + }, + "./provider-instance-ids": { + "types": "./src/providerInstanceIds.ts", + "default": "./src/providerInstanceIds.ts" } }, "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "effect-tsgo tsrs --noEmit", "test": "vp test run" }, "dependencies": { diff --git a/packages/client-runtime/src/connection/compatibility.test.ts b/packages/client-runtime/src/connection/compatibility.test.ts index e6a2cf3ca18f6..2225f707af1ca 100644 --- a/packages/client-runtime/src/connection/compatibility.test.ts +++ b/packages/client-runtime/src/connection/compatibility.test.ts @@ -31,6 +31,7 @@ describe("orchestration protocol compatibility", () => { expect(socketUrl.searchParams.get("orchestrationProtocol")).toBe( String(ORCHESTRATION_PROTOCOL_VERSION), ); + expect(socketUrl.searchParams.get("clientCapabilities")).toBe("settingsSecretReferences"); expect(socketUrl.searchParams.get("wsTicket")).toBe("secret"); expect(socketUrl.searchParams.get("connectionMethod")).toBe("relay"); }); diff --git a/packages/client-runtime/src/connection/compatibility.ts b/packages/client-runtime/src/connection/compatibility.ts index 6e97b86f48475..1af6d134a9b2e 100644 --- a/packages/client-runtime/src/connection/compatibility.ts +++ b/packages/client-runtime/src/connection/compatibility.ts @@ -1,6 +1,8 @@ import { + CLIENT_CAPABILITIES_QUERY_PARAM, ORCHESTRATION_PROTOCOL_QUERY_PARAM, ORCHESTRATION_PROTOCOL_VERSION, + SETTINGS_SECRET_REFERENCES_CAPABILITY, type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; @@ -27,5 +29,6 @@ export function orchestrationProtocolCompatibilityError( export function appendOrchestrationProtocol(socketUrl: string): string { const url = new URL(socketUrl); url.searchParams.set(ORCHESTRATION_PROTOCOL_QUERY_PARAM, String(ORCHESTRATION_PROTOCOL_VERSION)); + url.searchParams.set(CLIENT_CAPABILITIES_QUERY_PARAM, SETTINGS_SECRET_REFERENCES_CAPABILITY); return url.toString(); } diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 57defa0456af1..285f11288763b 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -2,7 +2,6 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { EnvironmentCacheStore, EnvironmentOwnedDataCleanup } from "../platform/persistence.ts"; @@ -11,7 +10,7 @@ import * as ConnectionDriver from "./driver.ts"; import * as EnvironmentRegistry from "./registry.ts"; import * as ConnectionWakeups from "./wakeups.ts"; -const noValue = Effect.succeed(Option.none()); +const noValue = Effect.succeedNone; function makeRegistryLayer(cleared: { cache: EnvironmentId[]; owned: EnvironmentId[] }) { return EnvironmentRegistry.layer.pipe( diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index df02394544302..471b53580e9e3 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -244,6 +244,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const connectivity = yield* Connectivity.Connectivity; const driver = yield* ConnectionDriver.ConnectionDriver; const wakeups = yield* ConnectionWakeups.ConnectionWakeups; + // Coder: every target is a gateway workspace, never upstream's primary environment, so no + // offline exemption for it (#29347). const initialIntent: SupervisorIntent = { desired: options?.initiallyDesired ?? false, network: yield* connectivity.status, diff --git a/packages/client-runtime/src/errors/diagnostics.ts b/packages/client-runtime/src/errors/diagnostics.ts index 258003a3da0a3..d3ab5c6a5c367 100644 --- a/packages/client-runtime/src/errors/diagnostics.ts +++ b/packages/client-runtime/src/errors/diagnostics.ts @@ -59,6 +59,7 @@ export function reportErrorDiagnostic(source: string, error: unknown): void { reporter({ ...safeErrorLogAttributes(error), source, + // @effect-diagnostics-next-line globalDate:off -- Coder: reports run synchronously, outside Effect. timestamp: new Date().toISOString(), message: diagnosticMessage(error), }); diff --git a/packages/client-runtime/src/handoff.ts b/packages/client-runtime/src/handoff.ts index c4e7b09595284..48a1227429312 100644 --- a/packages/client-runtime/src/handoff.ts +++ b/packages/client-runtime/src/handoff.ts @@ -24,7 +24,7 @@ export type HandoffTimelineRun = Pick< export type HandoffEndpoints = ReturnType; export function resolvePendingHandoff( - projection: ThreadStructure | null | undefined, + projection: Pick | null | undefined, selection: ModelSelection | null | undefined, providers: ReadonlyArray, ): HandoffEndpoints | null { diff --git a/packages/client-runtime/src/mcpApps/host.test.ts b/packages/client-runtime/src/mcpApps/host.test.ts index 8588770715814..898588737c31f 100644 --- a/packages/client-runtime/src/mcpApps/host.test.ts +++ b/packages/client-runtime/src/mcpApps/host.test.ts @@ -206,7 +206,7 @@ describe("makeMcpAppHost", () => { const { host, sent } = setup({ callTool: async (input) => { if (input.name === "delete_everything") - throw new McpAppHostRefusal("Declined by the user."); + throw new McpAppHostRefusal({ message: "Declined by the user." }); return { content: [], structuredContent: { ok: true } }; }, }); @@ -311,7 +311,7 @@ describe("makeMcpAppHost", () => { updateModelContext: async (update) => void contexts.push(update), downloadFile: async (files) => { downloads.push(files); - if (files.length > 1) throw new McpAppHostRefusal("Declined by the user."); + if (files.length > 1) throw new McpAppHostRefusal({ message: "Declined by the user." }); }, }); initialize(host); diff --git a/packages/client-runtime/src/mcpApps/host.ts b/packages/client-runtime/src/mcpApps/host.ts index 5eee10af4338a..847771fb09dbd 100644 --- a/packages/client-runtime/src/mcpApps/host.ts +++ b/packages/client-runtime/src/mcpApps/host.ts @@ -3,6 +3,7 @@ import { MCP_APP_PROTOCOL_VERSION, type McpAppReference } from "@t3tools/shared/ import * as Base64 from "effect/encoding/Base64"; import * as Predicate from "effect/Predicate"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; const MAX_MESSAGE_BYTES = 256 * 1024; const MAX_PENDING_REQUESTS = 16; @@ -71,7 +72,12 @@ export interface McpAppCallToolResult { readonly _meta?: unknown; } -export class McpAppHostRefusal extends Error {} +export class McpAppHostRefusal extends Schema.TaggedError()( + "McpAppHostRefusal", + { message: Schema.String }, +) {} + +const isMcpAppHostRefusal = Schema.is(McpAppHostRefusal); export interface McpAppHostOptions { readonly app: McpAppReference; @@ -141,7 +147,7 @@ function normalizeMcpAppToolResult(result: McpAppCallToolResult): McpAppCallTool } const errorMessage = (error: unknown) => - error instanceof McpAppHostRefusal + isMcpAppHostRefusal(error) ? error.message : error instanceof Error && error.message.trim() !== "" ? error.message @@ -342,7 +348,7 @@ export function makeMcpAppHost(options: McpAppHostOptions): McpAppHost { options.downloadFile(files).then( () => ({}), (error: unknown) => - error instanceof McpAppHostRefusal ? { isError: true } : Promise.reject(error), + isMcpAppHostRefusal(error) ? { isError: true } : Promise.reject(error), ), ); return; diff --git a/packages/client-runtime/src/operations/commands.performance.test.ts b/packages/client-runtime/src/operations/commands.performance.test.ts index b04f4318dabb0..d6f41b0b397a1 100644 --- a/packages/client-runtime/src/operations/commands.performance.test.ts +++ b/packages/client-runtime/src/operations/commands.performance.test.ts @@ -11,7 +11,7 @@ import { type OrchestrationV2TurnItem, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -137,12 +137,9 @@ const TARGET = new ConnectionTarget({ wsBaseUrl: "wss://environment.example.test", }); -const layerTestCrypto = Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: (_algorithm, data) => Effect.succeed(data), - }), +const layerTestRandom = Layer.succeed( + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size)), ); const makeSupervisor = Effect.fn("CommandPerformance.makeSupervisor")(function* (input: { @@ -174,7 +171,7 @@ const makeSupervisor = Effect.fn("CommandPerformance.makeSupervisor")(function* }, }, } as never), - subscribeServerConfig: (requestInput) => client.subscribeServerConfig(requestInput), + subscribeServerConfig: (requestInput = {}) => client.subscribeServerConfig(requestInput), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), @@ -272,6 +269,6 @@ describe("routine command transport budget", () => { }); expect(currentRpcJsonBytes).toBeLessThanOrEqual(MAX_CURRENT_SEND_RPC_JSON_BYTES); expect(currentRpcJsonBytes / formerRpcJsonBytes).toBeLessThanOrEqual(0.000_06); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); }); diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 94c7daebd1794..cd7b2746113c5 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -20,7 +20,7 @@ import { type ProjectMutation, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -43,12 +43,9 @@ import { startThreadTurn, } from "./commands.ts"; -const layerTestCrypto = Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: (_algorithm, data) => Effect.succeed(data), - }), +const layerTestRandom = Layer.succeed( + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size)), ); const TARGET = new ConnectionTarget({ @@ -116,7 +113,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct }, }, } as never), - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), @@ -153,7 +150,7 @@ describe("V2 environment commands", () => { { type: "queued-run.start", commandId: "send-selected", threadId, runId: "selected" }, { type: "queue.resume", commandId: "resume-queue", threadId }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect("preserves caller command ids for idempotent V2 commands", () => @@ -169,7 +166,7 @@ describe("V2 environment commands", () => { expect(commands).toEqual([ { type: "thread.archive", commandId: "queued-command", threadId: "thread-1" }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect("resolves run ordinal zero to the persisted thread-start checkpoint", () => @@ -213,7 +210,7 @@ describe("V2 environment commands", () => { checkpointId, }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect("preserves an existing worktree and branch during first-message launch", () => @@ -257,7 +254,7 @@ describe("V2 environment commands", () => { branch: "feature", }, }); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect("uses server-resolved delivery intent without fetching the full projection", () => @@ -293,7 +290,7 @@ describe("V2 environment commands", () => { }); } expect(projectionRequests).toEqual([]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect("Stop with no run left ends the thread's pull request watches", () => @@ -347,7 +344,7 @@ describe("V2 environment commands", () => { watching: false, }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect.each(["waiting", "completed", "rolled_back"] as const)( @@ -418,7 +415,7 @@ describe("V2 environment commands", () => { }, ], ); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect( @@ -472,7 +469,7 @@ describe("V2 environment commands", () => { subagentId, }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect( @@ -531,7 +528,7 @@ describe("V2 environment commands", () => { }, { type: "thread.stop", commandId: "lineage-stop", threadId: childThreadId }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect("stops a thread without relying on background work in the history window", () => @@ -559,6 +556,6 @@ describe("V2 environment commands", () => { threadId: v2ThreadId, }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); }); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 959358b3a9cde..29c70946061d2 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -30,7 +30,7 @@ import { } from "@t3tools/contracts"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import { getInitialServerConfig, request } from "../rpc/client.ts"; @@ -270,8 +270,8 @@ const allocateCommandId = Effect.fn("EnvironmentCommands.allocateCommandId")(fun input: CommandMetadata, ) { if (input.commandId !== undefined) return input.commandId; - const crypto = yield* Crypto.Crypto; - return CommandId.make(yield* crypto.randomUUIDv4().pipe(Effect.orDie)); + const random = yield* SecureRandom.SecureRandom; + return CommandId.make(yield* random.uuidV4()); }); const dispatch = (command: OrchestrationV2Command) => diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 406443a21dbb5..02cf3c9d6bdd1 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -5,7 +5,10 @@ import * as Option from "effect/Option"; import { findExistingAddProject, + getCloneDestinationPath, + getCloneDirectoryLeafName, getCloneDirectoryName, + getCloneDirectoryPath, getDefaultCloneUrl, getNewProjectPublishTarget, getNewProjectPublishTargets, @@ -97,6 +100,38 @@ describe("add project shared logic", () => { expect(getCloneDirectoryName(" https://github.com/owner/repo.git ")).toBe("repo"); }); + it("lays clone folders out like ghq only when asked, and only when the URL is safe", () => { + expect(getCloneDirectoryPath("git@github.com:owner/repo.git", false)).toBe("repo"); + expect(getCloneDirectoryPath("git@github.com:owner/repo.git", true)).toBe( + "github.com/owner/repo", + ); + expect(getCloneDirectoryPath("ssh://git@gitlab.com:2222/group/sub/repo.git", true)).toBe( + "gitlab.com/group/sub/repo", + ); + expect(getCloneDirectoryPath("https://github.com/owner/../../repo", true)).toBe("repo"); + expect(getCloneDirectoryPath("/srv/git/repo.git", true)).toBe("repo"); + expect( + getCloneDirectoryPath( + "git@ssh.dev.azure.com:v3/org/Project/My%20Repo", + false, + "Project/My Repo", + ), + ).toBe("My Repo"); + expect( + getCloneDirectoryLeafName( + "git@ssh.dev.azure.com:v3/org/Project/My%20Repo", + true, + "Project/My Repo", + ), + ).toBe("My%20Repo"); + expect( + getCloneDestinationPath( + "C:\\Users\\me\\ghq", + getCloneDirectoryPath("git@github.com:owner/repo.git", true), + ), + ).toBe("C:\\Users\\me\\ghq\\github.com\\owner\\repo"); + }); + it("keeps a numeric repository name that sits on a path", () => { expect(getCloneDirectoryName("https://github.com/acme/123.git")).toBe("123"); expect(getCloneDirectoryName("https://github.com/acme/123")).toBe("123"); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index cbdb885bdaa10..aab4f0f284290 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -9,6 +9,7 @@ import type { SourceControlRepositoryInfo, } from "@t3tools/contracts"; import { SourceControlProviderKind } from "@t3tools/contracts"; +import { cloneUrlRepositoryPath } from "@t3tools/shared/cloneUrl"; import { newProjectFolderName } from "@t3tools/shared/path"; import * as Arr from "effect/Array"; import * as Option from "effect/Option"; @@ -207,6 +208,26 @@ export function getCloneDirectoryName(repositoryOrRemoteUrl: string | null | und return lastSegment.endsWith(".git") ? lastSegment.slice(0, -".git".length) : lastSegment; } +/** The folder a clone gets below its base: ``, or `//` like ghq. */ +export function getCloneDirectoryPath( + remoteUrl: string, + ghqCloneLayout: boolean, + repository = remoteUrl, +): string { + return ( + (ghqCloneLayout ? cloneUrlRepositoryPath(remoteUrl) : null) ?? getCloneDirectoryName(repository) + ); +} + +/** The clone's own folder, which folder browsing keeps at the end of the destination. */ +export function getCloneDirectoryLeafName( + remoteUrl: string, + ghqCloneLayout: boolean, + repository = remoteUrl, +): string { + return getCloneDirectoryPath(remoteUrl, ghqCloneLayout, repository).split("/").at(-1) ?? ""; +} + export function getCloneDestinationPath( directoryPath: string, directoryName: string | null | undefined, @@ -215,7 +236,8 @@ export function getCloneDestinationPath( if (name.length === 0) { return directoryPath; } - return `${ensureBrowseDirectoryPath(directoryPath)}${name}`; + const base = ensureBrowseDirectoryPath(directoryPath); + return `${base}${base.endsWith("\\") ? name.replaceAll("/", "\\") : name}`; } export function getNewProjectPathPreview(newProjectsRoot: string, name: string): string { diff --git a/packages/client-runtime/src/platform/threadCacheEncoding.ts b/packages/client-runtime/src/platform/threadCacheEncoding.ts index b7c233942de43..f993ff3ec2038 100644 --- a/packages/client-runtime/src/platform/threadCacheEncoding.ts +++ b/packages/client-runtime/src/platform/threadCacheEncoding.ts @@ -2,7 +2,6 @@ import { omitLocalVisibleTurnItems, restoreLocalVisibleTurnItems, } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -23,7 +22,10 @@ const StoredCompactThreadSnapshot = StoredOrchestrationThreadSnapshot.mapFields( })), })); -class InvalidThreadCache extends Data.TaggedError("InvalidThreadCache") {} +class InvalidThreadCache extends Schema.TaggedError()( + "InvalidThreadCache", + {}, +) {} const encodeCompact = Schema.encodeEffect(Schema.fromJsonString(StoredCompactThreadSnapshot)); const decodeCompatible = Schema.decodeUnknownEffect( diff --git a/packages/client-runtime/src/projectFaviconCache.test.ts b/packages/client-runtime/src/projectFaviconCache.test.ts index 6af70685145da..dd03c314ecc4f 100644 --- a/packages/client-runtime/src/projectFaviconCache.test.ts +++ b/packages/client-runtime/src/projectFaviconCache.test.ts @@ -202,11 +202,19 @@ describe("project favicon image loader", () => { await expect(load(url, signal())).rejects.toThrow("too large"); expect(pulled).toBeLessThan(PROJECT_FAVICON_MAX_SOURCE_BYTES / chunk.byteLength + 3); expect(downscale).not.toHaveBeenCalled(); + let cancelled = false; + const declaredStream = new ReadableStream({ + cancel() { + cancelled = true; + }, + }); const declared = loader( - new Response("x", { + new Response(declaredStream, { headers: { "content-type": "image/png", "content-length": String(2 ** 40) }, }), ); await expect(declared.load(url, signal())).rejects.toThrow("too large"); + expect(cancelled).toBe(true); + expect(declared.downscale).not.toHaveBeenCalled(); }); }); diff --git a/packages/client-runtime/src/projectFaviconCache.ts b/packages/client-runtime/src/projectFaviconCache.ts index 0e53de3136b9e..d779a27acebef 100644 --- a/packages/client-runtime/src/projectFaviconCache.ts +++ b/packages/client-runtime/src/projectFaviconCache.ts @@ -50,7 +50,10 @@ export interface ProjectFaviconStorage { async function readBounded(response: Response, maxBytes: number) { const declared = Number(response.headers.get("content-length")); - if (declared > maxBytes) throw new Error("Project icon is too large to decode."); + if (declared > maxBytes) { + await response.body?.cancel().catch(() => {}); + throw new Error("Project icon is too large to decode."); + } if (!response.body) { const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.byteLength > maxBytes) throw new Error("Project icon is too large to decode."); diff --git a/packages/client-runtime/src/providerInstanceIds.test.ts b/packages/client-runtime/src/providerInstanceIds.test.ts new file mode 100644 index 0000000000000..2609061f8116e --- /dev/null +++ b/packages/client-runtime/src/providerInstanceIds.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { deriveAvailableInstanceId } from "./providerInstanceIds.ts"; + +describe("deriveAvailableInstanceId", () => { + it("derives a collision-free instance id without exceeding the slug limit", () => { + const derive = (label: string) => `acpRegistry_${label}`; + const existing = new Set(["acpRegistry_gemini", "acpRegistry_gemini_2"]); + + expect(deriveAvailableInstanceId(derive, "gemini", existing)).toBe("acpRegistry_gemini_3"); + + const longBase = `acpRegistry_${"a".repeat(48)}`; + expect(deriveAvailableInstanceId(() => longBase, "ignored", new Set([longBase]))).toHaveLength( + 62, + ); + }); +}); diff --git a/packages/client-runtime/src/providerInstanceIds.ts b/packages/client-runtime/src/providerInstanceIds.ts new file mode 100644 index 0000000000000..942fdb6745398 --- /dev/null +++ b/packages/client-runtime/src/providerInstanceIds.ts @@ -0,0 +1,29 @@ +/** Limit the slug to 48 chars so the composed ProviderInstanceId fits its 64-char cap. */ +function slugifyLabel(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 48); +} + +export function deriveInstanceId(driver: string, label: string): string { + const slug = slugifyLabel(label); + return slug ? `${driver}_${slug}` : ""; +} + +export function deriveAvailableInstanceId( + derive: (label: string) => string, + label: string, + existing: ReadonlySet, +): string { + const base = derive(label); + if (!base || !existing.has(base)) return base; + + for (let suffix = 2; ; suffix += 1) { + const suffixText = `_${suffix}`; + const candidate = `${base.slice(0, 64 - suffixText.length)}${suffixText}`; + if (!existing.has(candidate)) return candidate; + } +} diff --git a/packages/client-runtime/src/pullRequestEditing.test.ts b/packages/client-runtime/src/pullRequestEditing.test.ts index 1b473f4657def..15d349a79274c 100644 --- a/packages/client-runtime/src/pullRequestEditing.test.ts +++ b/packages/client-runtime/src/pullRequestEditing.test.ts @@ -11,6 +11,10 @@ import { canEditPullRequestChangeRequest, canEditPullRequestComment, canResolvePullRequestThread, + descriptionDraftInForce, + settleDescriptionSave, + startDescriptionSave, + type DescriptionDraft, } from "./pullRequestEditing.ts"; type Subject = Pick; @@ -202,3 +206,44 @@ describe("canResolvePullRequestThread", () => { ).toBe(false); }); }); + +describe("description drafts", () => { + const url = "https://github.com/owner/repo/pull/1"; + const unticked = "- [ ] Ship it"; + const ticked = "- [x] Ship it"; + + it("hides readings taken during the saves and ends at the first one after them", () => { + // Ticking and unticking the same box saves the host's own body last. + let draft: DescriptionDraft | null = startDescriptionSave(null, { url, observedAt: 1 }, ticked); + draft = startDescriptionSave(draft, { url, observedAt: 1 }, unticked); + expect(descriptionDraftInForce(draft, url, 2)?.body).toBe(unticked); + draft = descriptionDraftInForce(draft, url, 2); + draft = settleDescriptionSave(draft, url, true); + expect(descriptionDraftInForce(draft, url, 2)?.body).toBe(unticked); + draft = settleDescriptionSave(draft, url, true); + expect(descriptionDraftInForce(draft, url, 2)?.body).toBe(unticked); + // The acknowledging reading ends the draft, so the host's later ticks show. + expect(descriptionDraftInForce(draft, url, 3)).toBeNull(); + }); + + it("outlives failed saves until a reading after the last one, losing the body only with it", () => { + let draft: DescriptionDraft | null = startDescriptionSave(null, { url, observedAt: 1 }, ticked); + draft = startDescriptionSave(draft, { url, observedAt: 1 }, unticked); + // The save still running writes the whole body again. + draft = settleDescriptionSave(draft, url, false); + expect(descriptionDraftInForce(draft, url, 2)).toMatchObject({ body: unticked, saves: 1 }); + draft = descriptionDraftInForce(draft, url, 2); + draft = settleDescriptionSave(draft, url, false); + expect(descriptionDraftInForce(draft, url, 2)).toMatchObject({ body: null, saves: 0 }); + expect(settleDescriptionSave(draft, url, true)).toBe(draft); + expect(descriptionDraftInForce(draft, url, 3)).toBeNull(); + }); + + it("belongs to one change request", () => { + const draft = startDescriptionSave(null, { url, observedAt: 1 }, ticked); + const other = "https://github.com/owner/repo/pull/2"; + expect(descriptionDraftInForce(draft, other, 1)).toBeNull(); + expect(settleDescriptionSave(draft, other, false)).toBe(draft); + expect(startDescriptionSave(draft, { url: other, observedAt: 5 }, unticked).saves).toBe(1); + }); +}); diff --git a/packages/client-runtime/src/pullRequestEditing.ts b/packages/client-runtime/src/pullRequestEditing.ts index 96b4d5c494f9b..fa92747f0cff2 100644 --- a/packages/client-runtime/src/pullRequestEditing.ts +++ b/packages/client-runtime/src/pullRequestEditing.ts @@ -25,6 +25,64 @@ export function canEditPullRequestChangeRequest(detail: EditingSubject): boolean ); } +/** + * A saved choice ends at the first host reading delivered after the save. Readers refresh once it + * succeeds, which drops reads still in flight, so that reading started after the write. + * + * Coder: unexported, since only the description draft uses it; GitHub maintainer edits (#25588, + * `canEditPullRequestMaintainerEdits`) have no web control. + */ +function maintainerEditsChoiceInForce< + Choice extends { readonly key: string; readonly observedAt: number | undefined }, +>( + choice: Choice | null, + key: string, + observedAt: number | undefined, + saving: boolean, +): Choice | null { + if (choice === null || choice.key !== key) return null; + if (saving) return choice.observedAt === observedAt ? choice : { ...choice, observedAt }; + return observedAt !== undefined && choice.observedAt === observedAt ? choice : null; +} + +/** A description written from this client, shown in place of the host's copy. */ +export interface DescriptionDraft { + readonly key: string; + readonly observedAt: number | undefined; + /** Null once the last save failed: until a host reading, nobody knows which saves landed. */ + readonly body: string | null; + /** Saves still running. The host applies them in order, so the last one is `body`. */ + readonly saves: number; +} + +/** Same lifetime as a maintainer edits choice: until the first host reading after the saves. */ +export function descriptionDraftInForce( + draft: DescriptionDraft | null, + key: string, + observedAt: number | undefined, +): DescriptionDraft | null { + return maintainerEditsChoiceInForce(draft, key, observedAt, (draft?.saves ?? 0) > 0); +} + +export function startDescriptionSave( + draft: DescriptionDraft | null, + detail: { readonly url: string; readonly observedAt?: number | undefined }, + body: string, +): DescriptionDraft { + const saves = draft?.key === detail.url ? draft.saves : 0; + return { key: detail.url, observedAt: detail.observedAt, body, saves: saves + 1 }; +} + +/** Each save writes the whole body, so a failure only loses `body` when no later save is running. */ +export function settleDescriptionSave( + draft: DescriptionDraft | null, + key: string, + saved: boolean, +): DescriptionDraft | null { + if (draft === null || draft.key !== key || draft.saves === 0) return draft; + const saves = draft.saves - 1; + return { ...draft, saves, body: saved || saves > 0 ? draft.body : null }; +} /** Hosts do not support rewriting review summaries through comment mutations. */ export function canEditPullRequestComment( detail: EditingSubject, diff --git a/packages/client-runtime/src/pullRequestMarkdown.test.ts b/packages/client-runtime/src/pullRequestMarkdown.test.ts index 876bf7e494478..92ebaa36a2fc9 100644 --- a/packages/client-runtime/src/pullRequestMarkdown.test.ts +++ b/packages/client-runtime/src/pullRequestMarkdown.test.ts @@ -12,7 +12,7 @@ describe("pull request body segmentation", () => { "Before\n\nhttps://gitlab.com/group/project/uploads/0123456789abcdef/demo.mp4\n\nAfter", ), ).toEqual([ - { id: "markdown:0", kind: "markdown", text: "Before" }, + { id: "markdown:0", kind: "markdown", text: "Before", sourceOffset: 0 }, { id: "attachment:1", kind: "attachment", @@ -20,7 +20,7 @@ describe("pull request body segmentation", () => { hostLabel: "GitLab", media: "video", }, - { id: "markdown:2", kind: "markdown", text: "After" }, + { id: "markdown:2", kind: "markdown", text: "After", sourceOffset: 76 }, ]); }); @@ -41,6 +41,23 @@ describe("pull request body segmentation", () => { ]); }); + it("places each prose run at its offset in the body", () => { + const body = + '\n\n- [ ] Before\r\n\nhttps://example.com/shot.png\n\n\n\n - [ ] code\n- [x] After\n'; + const prose = splitPullRequestBody(body).flatMap((segment) => + segment.kind === "markdown" ? [segment] : [], + ); + expect(prose.map((segment) => segment.text)).toEqual([ + "- [ ] Before", + " - [ ] code\n- [x] After", + ]); + for (const segment of prose) { + expect(body.slice(segment.sourceOffset, segment.sourceOffset + segment.text.length)).toBe( + segment.text, + ); + } + }); + it("reads the source out of a video tag, including a multi-line one", () => { expect( splitPullRequestBody( @@ -61,49 +78,49 @@ describe("pull request body segmentation", () => { const body = 'image'; expect(splitPullRequestBody(body)).toEqual([ - { id: "markdown:0", kind: "markdown", text: body }, + { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 }, ]); }); it("leaves an ordinary link alone, whether it is bare or written as markdown", () => { const body = "https://example.com/page\n\n[the docs](https://example.com/docs)"; expect(splitPullRequestBody(body)).toEqual([ - { id: "markdown:0", kind: "markdown", text: body }, + { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 }, ]); }); it("never lifts a link inside fenced code out of it", () => { const body = "```\nhttps://example.com/demo.mp4\n```"; expect(splitPullRequestBody(body)).toEqual([ - { id: "markdown:0", kind: "markdown", text: body }, + { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 }, ]); }); it("does not let a different fence marker close an open fence", () => { const body = "```\n~~~\nhttps://example.com/demo.mp4\n```"; expect(splitPullRequestBody(body)).toEqual([ - { id: "markdown:0", kind: "markdown", text: body }, + { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 }, ]); }); it("keeps a video tag that shares its line with prose as markdown", () => { const body = 'Before after'; expect(splitPullRequestBody(body)).toEqual([ - { id: "markdown:0", kind: "markdown", text: body }, + { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 }, ]); }); it("treats an info-string fence as a nested opener, not a close", () => { const body = "```\n```javascript\nhttps://example.com/demo.mp4\n```"; expect(splitPullRequestBody(body)).toEqual([ - { id: "markdown:0", kind: "markdown", text: body }, + { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 }, ]); }); it("leaves an indented code line alone even when it is only a video link", () => { const body = " https://example.com/demo.mp4"; expect(splitPullRequestBody(body)).toEqual([ - { id: "markdown:0", kind: "markdown", text: body }, + { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 }, ]); }); @@ -112,14 +129,14 @@ describe("pull request body segmentation", () => { "\n", ); expect(splitPullRequestBody(body)).toEqual([ - { id: "markdown:0", kind: "markdown", text: body }, + { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 }, ]); }); it("refuses a non-http source rather than making a link out of it", () => { const body = ''; expect(splitPullRequestBody(body)).toEqual([ - { id: "markdown:0", kind: "markdown", text: body }, + { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 }, ]); }); }); @@ -139,7 +156,7 @@ describe("media on custom hosts", () => { const body = "See [recording](https://drop.example.com/a.mp4) for context.\n\n```md\nhttps://drop.example.com/a.png\n```"; expect(splitPullRequestBody(body)).toEqual([ - { id: "markdown:0", kind: "markdown", text: body }, + { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 }, ]); }); }); diff --git a/packages/client-runtime/src/pullRequestMarkdown.ts b/packages/client-runtime/src/pullRequestMarkdown.ts index 77f253b5345e4..4eafd59c7d111 100644 --- a/packages/client-runtime/src/pullRequestMarkdown.ts +++ b/packages/client-runtime/src/pullRequestMarkdown.ts @@ -2,7 +2,13 @@ import { sourceControlClients, type ReferenceAutolink } from "./sourceControlCli /** `id` is positional on purpose: the same attachment can be embedded twice in one body. */ export type PullRequestBodySegment = - | { readonly id: string; readonly kind: "markdown"; readonly text: string } + | { + readonly id: string; + readonly kind: "markdown"; + readonly text: string; + /** Where `text` starts in the body, so an edit made to it can be written back. */ + readonly sourceOffset: number; + } | { readonly id: string; readonly kind: "attachment"; @@ -94,21 +100,31 @@ export function splitPullRequestBody( ): ReadonlyArray { const segments: PullRequestBodySegment[] = []; const markdown: string[] = []; + let markdownStart = 0; let openFence: string | null = null; // Blank lines around a run are dropped, but never leading spaces: four of them open an // indented code block, so trimming a run would silently turn code into prose. const flushMarkdown = () => { - const text = markdown.join("\n").replace(/^\n+/u, "").replace(/\s+$/u, ""); + const joined = markdown.join("\n"); + const text = joined.replace(/^\n+/u, ""); + const sourceOffset = markdownStart + joined.length - text.length; markdown.length = 0; if (text.trim().length > 0) { - segments.push({ id: `markdown:${segments.length}`, kind: "markdown", text }); + segments.push({ + id: `markdown:${segments.length}`, + kind: "markdown", + text: text.replace(/\s+$/u, ""), + sourceOffset, + }); } }; const lines = body.split("\n"); - for (let index = 0; index < lines.length; index += 1) { + let lineStart = 0; + for (let index = 0; index < lines.length; lineStart += lines[index]!.length + 1, index += 1) { const line = lines[index]!; + if (markdown.length === 0) markdownStart = lineStart; const fenceMatch = FENCE_PATTERN.exec(line); if (fenceMatch !== null) { const fence = fenceMatch[1]!; @@ -164,7 +180,7 @@ export function splitPullRequestBody( media: "video", }); - index = cursor; + for (; index < cursor; index += 1) lineStart += lines[index]!.length + 1; } else { // Unclosed, or nothing linkable in it: prose. Only this line is consumed, so the // lines that were looked at are still parsed on their own terms. diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index adf4ade5c1686..8b61ca72dc8fe 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -233,6 +233,7 @@ export function runStreamGuarded( } interface SubscriptionOptions { + /** Reports defects; the subscription then waits for the next session or resubscribe. */ readonly onDefect?: ( cause: Cause.Cause>, ) => Effect.Effect; @@ -370,6 +371,11 @@ function subscribeDynamicMapped( Stream.concat(subscribeToSession()), ); } + // A reported defect parks until the next session or resubscribe. Failing would + // end the whole subscription, so a mounted screen never syncs again. + if (options?.onDefect !== undefined && Cause.hasDies(cause)) { + return Stream.empty; + } return Stream.failCause(cause); }), ), diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 5e34bf3aff2e4..512f728b15f39 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -5,6 +5,7 @@ import { type ServerConfig as ServerConfigType, WS_METHODS, } from "@t3tools/contracts"; +import { splitSocketFrame } from "@t3tools/shared/socketFrameParts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -182,6 +183,36 @@ const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialCo }); describe("RpcSessionFactory", () => { + it.effect("connects while a large config snapshot downloads at 4000 bytes per second", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + const request = yield* awaitRequest(socket); + // Multi-byte text, so parts must be sized in UTF-8 bytes and never split a surrogate pair. + const config = { ...SERVER_CONFIG, cwd: "/" + "é€😀x".repeat(32 * 1024) }; + const frame = encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: [{ version: 1, type: "snapshot", config: encodeServerConfig(config) }], + }); + const parts = new URL(socket.url).searchParams.has("frameParts") + ? splitSocketFrame(frame) + : [frame]; + + for (const part of parts) { + const bytes = new TextEncoder().encode(part); + yield* TestClock.adjust((bytes.length / 4000) * 1000); + socket.serverMessage(new TextDecoder().decode(bytes)); + } + yield* Fiber.join(readyFiber); + + expect(yield* session.initialConfig).toEqual(config); + }), + ); + it.effect("uses one config subscription for initial sync", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 34797f929367b..e3eb44c588315 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -4,6 +4,10 @@ import { WsSubscribeServerConfigRpc, WS_METHODS, } from "@t3tools/contracts"; +import { + joinSocketFrameParts, + SOCKET_FRAME_PARTS_QUERY_PARAM, +} from "@t3tools/shared/socketFrameParts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; @@ -45,7 +49,7 @@ export interface RpcSession { readonly client: WsRpcProtocolClient; readonly initialConfig: Effect.Effect; readonly subscribeServerConfig: ( - input: ServerConfigSubscriptionInput, + input?: ServerConfigSubscriptionInput, ) => ServerConfigSubscription; readonly ready: Effect.Effect; readonly probe: Effect.Effect; @@ -176,9 +180,17 @@ const make = Effect.gen(function* () { }); return socket; }; - const layerSocket = Socket.layerWebSocket(connection.socketUrl, { - openTimeout: SOCKET_OPEN_TIMEOUT, - }).pipe( + const socketUrl = new URL(connection.socketUrl); + socketUrl.searchParams.set(SOCKET_FRAME_PARTS_QUERY_PARAM, "1"); + const layerSocket = Layer.effect( + Socket.Socket, + Effect.map(Effect.service(Socket.Socket), joinSocketFrameParts), + ).pipe( + Layer.provide( + Socket.layerWebSocket(socketUrl.toString(), { + openTimeout: SOCKET_OPEN_TIMEOUT, + }), + ), Layer.provide(Layer.succeed(Socket.WebSocketConstructor, countingWebSocketConstructor)), ); const layerProtocol = Layer.effect( @@ -281,13 +293,13 @@ const make = Effect.gen(function* () { Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)), ), ).pipe( - Effect.flatMap((config) => - config.environment.environmentId === connection.environmentId - ? Effect.succeed(config) - : new ConnectionBlockedError({ - reason: "configuration", - detail: `Connected environment ${config.environment.environmentId} does not match ${connection.environmentId}.`, - }), + Effect.filterOrFail( + (config) => config.environment.environmentId === connection.environmentId, + (config) => + new ConnectionBlockedError({ + reason: "configuration", + detail: `Connected environment ${config.environment.environmentId} does not match ${connection.environmentId}.`, + }), ), Effect.withSpan("environment.initialSync"), ); @@ -342,7 +354,7 @@ const make = Effect.gen(function* () { }), ), ); - const subscribeServerConfig = (input: ServerConfigSubscriptionInput) => + const subscribeServerConfig = (input: ServerConfigSubscriptionInput = serverConfigInput) => Stream.unwrap( validatedInitialConfig.pipe( Effect.as( diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index ae4157805f0dc..77bedb4b29928 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; -import { type AssetCreateUrlResult, EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import { + type AssetCreateUrlResult, + type AssetResource, + EnvironmentId, + ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -22,6 +28,7 @@ import { ASSET_URL_MAX_IN_FLIGHT, createAssetEnvironmentAtoms, createProjectFaviconUrlAtomFamily, + makeRevisionedAssetUrlFamily, } from "./assets.ts"; describe("project favicon URL cache", () => { @@ -230,3 +237,151 @@ describe("asset URL requests", () => { ), ); }); + +const revisionedEnvironmentId = EnvironmentId.make("environment-1"); +const revisionedResource: AssetResource = { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: "docs/diagram.png", +}; + +type MintResult = AsyncResult.AsyncResult<{ + relativeUrl: string; + expiresAt: number; + fileRevision?: string; +}>; + +const minted = (n: number, fileRevision?: string): MintResult => + AsyncResult.success({ + relativeUrl: `/a/${n}`, + expiresAt: 60 * 60_000, + ...(fileRevision === undefined ? {} : { fileRevision }), + }); + +// A stand-in for the asset URL query: a refresh starts a mint (counted, shown as +// waiting) and `settle` completes the latest one. +function setup(initial: MintResult) { + let mints = 0; + // Node removal runs on the registry's scheduler; `unmount` flushes it so the + // next mount of the same revision builds a fresh node. + const scheduled: Array<() => void> = []; + const registry = AtomRegistry.make({ + scheduleTask: (task) => { + scheduled.push(task); + return () => {}; + }, + }); + const server = Atom.make(initial).pipe(Atom.keepAlive); + const query = Atom.readable( + (get) => get(server), + () => { + mints += 1; + registry.set(server, AsyncResult.waiting(registry.get(server))); + }, + ).pipe(Atom.keepAlive); + const revisioned = makeRevisionedAssetUrlFamily( + () => query, + () => 0, + ); + const atom = (revision: string | null) => + revisioned({ + environmentId: revisionedEnvironmentId, + input: { resource: revisionedResource }, + revision, + }); + const mount = (revision: string | null) => { + const release = registry.mount(atom(revision)); + return () => { + release(); + for (const task of scheduled.splice(0)) task(); + }; + }; + const settle = (fileRevision?: string) => registry.set(server, minted(mints + 1, fileRevision)); + const url = (revision: string | null) => { + const result = registry.get(atom(revision)); + return result._tag === "Success" ? result.value.relativeUrl : null; + }; + return { mount, settle, url, mints: () => mints }; +} + +describe("makeRevisionedAssetUrlFamily", () => { + it("re-mints a settled URL on first read and on each new revision, not on remount", () => { + const { mount, settle, mints } = setup(minted(0)); + + mount("mutation-1")(); + expect(mints()).toBe(1); + settle(); + + mount("mutation-1")(); + expect(mints()).toBe(1); + + mount("mutation-2")(); + expect(mints()).toBe(2); + }); + + it("re-mints when a revert returns to an earlier revision", () => { + const { mount, settle, mints } = setup(minted(0)); + + mount("mutation-a")(); + settle(); + mount("mutation-b")(); + settle(); + expect(mints()).toBe(2); + + const reverted = mount("mutation-a"); + expect(mints()).toBe(3); + reverted(); + }); + + it("shares a mint in flight on the first read", () => { + const { mount, mints } = setup(AsyncResult.initial(true)); + + mount("mutation-1")(); + expect(mints()).toBe(0); + }); + + it("re-mints after a mint that was in flight when the revision changed", () => { + const { mount, settle, mints } = setup(minted(0)); + + const before = mount("mutation-1"); + settle(); + const edited = mount("mutation-2"); + expect(mints()).toBe(2); + before(); + + const stillMinting = mount("mutation-3"); + edited(); + expect(mints()).toBe(2); + + settle(); + expect(mints()).toBe(3); + settle(); + expect(mints()).toBe(3); + stillMinting(); + }); + + it("keeps the URL while the file is unchanged and swaps it once the file changes", () => { + const { mount, settle, url } = setup(minted(0, "file-a")); + + const first = mount("mutation-1"); + settle("file-a"); + first(); + + const unchanged = mount("mutation-2"); + settle("file-a"); + expect(url("mutation-2")).toBe("/a/0"); + unchanged(); + + // The query now holds /a/3; a later revision must still keep the URL on screen. + const stillUnchanged = mount("mutation-3"); + expect(url("mutation-3")).toBe("/a/0"); + settle("file-a"); + expect(url("mutation-3")).toBe("/a/0"); + stillUnchanged(); + + const edited = mount("mutation-4"); + settle("file-b"); + expect(url("mutation-4")).toBe("/a/5"); + edited(); + }); +}); diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 2845ca66cf272..52dc48922dd4f 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -133,6 +133,113 @@ export function assetUrlStateFromResult( }; } +type AssetUrlTarget = { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; +}; + +const decodeRevisionedAssetUrlKey = Schema.decodeUnknownSync( + Schema.Tuple([EnvironmentId, AssetResource, Schema.NullOr(Schema.String)]), +); + +const isSettled = (result: AsyncResult.AsyncResult) => + !AsyncResult.isInitial(result) && !result.waiting; + +const KEEP_URL_MIN_LIFE_MS = 5 * 60_000; + +/** + * A re-mint signs a new URL even when the file is unchanged, and a new URL reloads the image. + * Keep the shown URL while the server reports the same file revision and it has time left. + */ +function keepUnchangedFile( + shown: AsyncResult.AsyncResult, + next: AsyncResult.AsyncResult, + now: number, +) { + if ( + shown._tag !== "Success" || + next._tag !== "Success" || + next.value.fileRevision === undefined || + next.value.fileRevision !== shown.value.fileRevision || + shown.value.expiresAt - now <= KEEP_URL_MIN_LIFE_MS + ) { + return next; + } + const kept = AsyncResult.success(shown.value); + return next.waiting ? AsyncResult.waiting(kept) : kept; +} + +/** + * The URL is minted per resource and shared across revisions, so reading a + * different revision (even an earlier one after a revert) re-mints it. An + * in-flight mint is shared only on first read; after an edit the server must + * re-read the file, so the re-mint waits for that mint to settle. + */ +export function makeRevisionedAssetUrlFamily( + createUrl: ( + target: AssetUrlTarget, + ) => Atom.Atom>, + now: () => number = Date.now, +) { + // Outside the nodes: a node is dropped once unmounted, and the next read of + // its revision must still see the revisions minted since. Never pruned on + // purpose: pruning would forget the revision and re-mint on the next mount. + // It holds one short entry per file shown, and goes with the registry. + const mintedUnder = new WeakMap>(); + // Also per resource: a new revision builds a new node, which must keep the URL on screen. + const shownUnder = new WeakMap< + AtomRegistry.AtomRegistry, + Map> + >(); + const family = Atom.family((key: string) => { + const [environmentId, resource, revision] = decodeRevisionedAssetUrlKey(JSON.parse(key)); + const resourceKey = JSON.stringify([environmentId, resource]); + const query = createUrl({ environmentId, input: { resource } }); + return Atom.transform( + query, + (get) => { + let minted = mintedUnder.get(get.registry); + if (minted === undefined) { + minted = new Map(); + mintedUnder.set(get.registry, minted); + } + const firstRead = !minted.has(resourceKey); + let pending = minted.get(resourceKey) !== revision; + const current = get.once(query); + if (pending && (firstRead || isSettled(current))) { + pending = false; + minted.set(resourceKey, revision); + if (isSettled(current)) get.refresh(query); + } + let shownByResource = shownUnder.get(get.registry); + if (shownByResource === undefined) { + shownByResource = new Map(); + shownUnder.set(get.registry, shownByResource); + } + const show = (value: AsyncResult.AsyncResult) => { + const previous = shownByResource.get(resourceKey); + const shown = previous === undefined ? value : keepUnchangedFile(previous, value, now()); + shownByResource.set(resourceKey, shown); + return shown; + }; + get.subscribe(query, (value) => { + get.setSelf(show(value)); + if (!pending || !isSettled(value)) return; + pending = false; + minted.set(resourceKey, revision); + get.refresh(query); + }); + return show(get.once(query)); + }, + { initialValueTarget: query }, + // No idle TTL: each mount rebuilds the node, so a revision read again after + // another one is compared against the record instead of a cached node. + ).pipe(Atom.setIdleTTL(0)); + }); + return (target: AssetUrlTarget & { readonly revision: string | null }) => + family(JSON.stringify([target.environmentId, target.input.resource, target.revision])); +} + export function createAssetEnvironmentAtoms( runtime: Atom.AtomRuntime, localMediaEnvironment?: Atom.Atom<{ diff --git a/packages/client-runtime/src/state/attachments.test.ts b/packages/client-runtime/src/state/attachments.test.ts index c1376bc786fd5..a5396dcc0c66c 100644 --- a/packages/client-runtime/src/state/attachments.test.ts +++ b/packages/client-runtime/src/state/attachments.test.ts @@ -9,7 +9,11 @@ import * as Effect from "effect/Effect"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import type { AtomCommand } from "./runtime.ts"; -import { runAttachmentUploadCycle, verifyPersistedAttachmentUpload } from "./attachments.ts"; +import { + formatAttachmentSize, + runAttachmentUploadCycle, + verifyPersistedAttachmentUpload, +} from "./attachments.ts"; const environmentId = EnvironmentId.make("environment-1"); const registry = {} as AtomRegistry.AtomRegistry; @@ -129,3 +133,14 @@ describe("verifyPersistedAttachmentUpload", () => { expect(lookups).toBe(2); }); }); + +describe("formatAttachmentSize", () => { + it.each([ + [1_047_552, "1023 KB"], + [1_047_553, "1.0 MB"], + [1_048_575, "1.0 MB"], + [1_048_576, "1.0 MB"], + ])("formats %d bytes as %s", (sizeBytes, label) => { + expect(formatAttachmentSize(sizeBytes)).toBe(label); + }); +}); diff --git a/packages/client-runtime/src/state/attachments.ts b/packages/client-runtime/src/state/attachments.ts index eabbbe16002ea..b63568ae1e2de 100644 --- a/packages/client-runtime/src/state/attachments.ts +++ b/packages/client-runtime/src/state/attachments.ts @@ -195,9 +195,9 @@ export function clampFileAttachmentUploadBytes(advertisedMaxUploadBytes: number) } export function formatAttachmentSize(sizeBytes: number): string { - return sizeBytes >= 1024 * 1024 - ? `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB` - : `${Math.max(1, Math.ceil(sizeBytes / 1024))} KB`; + const kilobytes = Math.max(1, Math.ceil(sizeBytes / 1024)); + // Pick the unit after rounding, so 1023.5 KB reads "1.0 MB", not "1024 KB". + return kilobytes >= 1024 ? `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB` : `${kilobytes} KB`; } export function fileAttachmentTooLargeMessage(name: string, maxUploadBytes: number): string { diff --git a/packages/client-runtime/src/state/contextWindow.test.ts b/packages/client-runtime/src/state/contextWindow.test.ts new file mode 100644 index 0000000000000..3f1a4d3d41994 --- /dev/null +++ b/packages/client-runtime/src/state/contextWindow.test.ts @@ -0,0 +1,264 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + RunId, + type ServerProvider, + type ServerSettings, +} from "@t3tools/contracts"; +import { + CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + formatClaudeResumeCompactionQuestion, +} from "@t3tools/shared/claudeCompaction"; +import * as DateTime from "effect/DateTime"; +import { describe, expect, it } from "vite-plus/test"; + +import { shouldCompactBeforeSend } from "./contextWindow.ts"; +import type { ThreadStructure } from "./models.ts"; +import { v2Projection } from "./orchestrationV2TestFixtures.ts"; + +const claude = ProviderInstanceId.make("claudeAgent"); +const claudeWork = ProviderInstanceId.make("claude_work"); +const claudeOtherHome = ProviderInstanceId.make("claude_other_home"); +const codex = ProviderInstanceId.make("codex"); +const lastTurnAt = "2026-10-30T14:23:02.000Z"; +const returnedAt = "2026-10-30T23:03:21.000Z"; + +function sendInput( + overrides: { + readonly usedTokens?: number; + readonly promptCacheTtlMs?: number; + readonly now?: string; + readonly settings?: Partial; + readonly source?: ProviderInstanceId; + readonly selected?: ProviderInstanceId; + readonly onlyEarlierConversationUsage?: boolean; + readonly unstartedFork?: boolean; + readonly slashCommands?: ReadonlyArray; + readonly dismissed?: boolean; + readonly heldQueue?: boolean; + readonly pendingRequest?: boolean; + readonly onlyCompactMessages?: boolean; + readonly promptOutsideWindow?: boolean; + readonly text?: string; + readonly threadIdle?: boolean; + } = {}, +) { + const source = overrides.source ?? claude; + const sourceSelection = { + instanceId: source, + model: source === codex ? "gpt-5.4" : "claude-opus-4-6", + }; + const providers = [ + { instanceId: claude, driver: "claudeAgent", groupKey: "claude:~/.claude" }, + { instanceId: claudeWork, driver: "claudeAgent", groupKey: "claude:~/.claude" }, + { instanceId: claudeOtherHome, driver: "claudeAgent", groupKey: "claude:~/.claude-other" }, + { instanceId: codex, driver: "codex", groupKey: "codex:~/.codex" }, + ].map(({ instanceId, driver, groupKey }) => ({ + instanceId, + driver: ProviderDriverKind.make(driver), + continuation: { groupKey }, + slashCommands: (overrides.slashCommands ?? ["compact"]).map((name) => ({ name })), + })) as unknown as ReadonlyArray; + const providerThread = (id: string) => ({ + id: ProviderThreadId.make(id), + driver: providers.find((provider) => provider.instanceId === source)!.driver, + providerInstanceId: source, + contextUsage: null, + updatedAt: DateTime.makeUnsafe(lastTurnAt), + }); + const visibleTurnItems = + overrides.promptOutsideWindow === true + ? [{ item: { type: "assistant_message", text: "Build fixed" } }] + : [ + { + item: { + type: "user_message", + runId: RunId.make("run-1"), + text: overrides.onlyCompactMessages === true ? "/compact" : "Fix the build", + attachments: [], + }, + }, + ]; + const projection = { + ...v2Projection, + thread: { + ...v2Projection.thread, + providerInstanceId: source, + modelSelection: sourceSelection, + activeProviderThreadId: ProviderThreadId.make("provider-thread-current"), + }, + providerThreads: [ + providerThread("provider-thread-earlier"), + providerThread("provider-thread-current"), + ], + providerTurns: [ + { + providerThreadId: ProviderThreadId.make( + overrides.onlyEarlierConversationUsage === true + ? "provider-thread-earlier" + : "provider-thread-current", + ), + tokenUsage: { + usedTokens: overrides.usedTokens ?? 293_611, + updatedAt: lastTurnAt, + ...(overrides.promptCacheTtlMs === undefined + ? {} + : { promptCacheTtlMs: overrides.promptCacheTtlMs }), + }, + }, + ], + runs: [ + { + id: RunId.make("run-1"), + ordinal: 1, + providerInstanceId: source, + modelSelection: sourceSelection, + status: "completed", + startedAt: "2026-10-30T14:20:00.000Z", + contextHandoffId: null, + }, + ...(overrides.heldQueue === true + ? [{ id: RunId.make("queued-run"), status: "queued", queueHeld: true, startedAt: null }] + : []), + ], + runtimeRequests: [ + ...(overrides.dismissed === true + ? [ + { + kind: "user_input", + status: "resolved", + answers: { + [formatClaudeResumeCompactionQuestion({ + ageMinutes: 90, + estimatedTokens: 250_000, + })]: CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + }, + }, + ] + : []), + ...(overrides.pendingRequest === true ? [{ kind: "approval", status: "pending" }] : []), + ], + visibleTurnItems, + } as unknown as ThreadStructure; + if (overrides.unstartedFork === true) { + Object.assign(projection, { + thread: { + ...projection.thread, + activeProviderThreadId: null, + forkedFrom: { type: "run", threadId: "source-thread", runId: "source-run" }, + }, + runs: [], + providerThreads: [], + providerTurns: [], + contextTransfers: [ + { + type: "fork", + targetThreadId: projection.thread.id, + sourceThreadId: "source-thread", + sourcePoint: { runId: "source-run" }, + sourceProviderInstanceId: source, + status: "pending", + sourceTokenUsage: { usedTokens: 293_611, updatedAt: lastTurnAt }, + }, + ], + }); + } + const selected = overrides.selected ?? claude; + return { + projection, + visibleTurnItems: visibleTurnItems as never, + selection: { + instanceId: selected, + model: selected === codex ? "gpt-5.4" : "claude-opus-4-6", + }, + providers, + settings: { ...DEFAULT_SERVER_SETTINGS, ...overrides.settings }, + text: overrides.text ?? "back again", + threadIdle: overrides.threadIdle ?? true, + now: overrides.now ?? returnedAt, + }; +} + +describe("shouldCompactBeforeSend", () => { + it.each([ + { name: "a stale Claude thread over 200K", expected: true }, + { + name: "when the original prompt is outside the loaded window", + promptOutsideWindow: true, + expected: true, + }, + { + name: "when the environment compacts before the cache expires", + settings: { idleCompaction: "beforeCacheExpires" }, + expected: true, + }, + { + name: "past a reported TTL shorter than the fallback", + promptCacheTtlMs: 5 * 60_000, + now: "2026-10-30T14:30:00.000Z", + expected: true, + }, + { name: "within the fallback TTL", now: "2026-10-30T15:23:02.000Z", expected: false }, + { name: "under 200K", usedTokens: 150_000, expected: false }, + { name: "with idle compaction off", settings: { idleCompaction: "off" }, expected: false }, + { + name: "with the Claude instance overriding idle compaction to off", + settings: { + idleCompaction: "onReturn", + providerInstances: { + [claude]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { idleCompaction: "off" }, + }, + }, + }, + expected: false, + }, + { name: "on a Codex thread", source: codex, selected: codex, expected: false }, + { + name: "after switching a stale Codex thread to Claude", + source: codex, + selected: claude, + expected: false, + }, + { + name: "after switching to a Claude instance that continues the conversation", + selected: claudeWork, + expected: true, + }, + { + name: "after switching to a Claude instance with another home", + selected: claudeOtherHome, + expected: false, + }, + { + name: "when only an earlier conversation reported usage", + onlyEarlierConversationUsage: true, + expected: false, + }, + { name: "an unstarted fork resuming its source instance", unstartedFork: true, expected: true }, + { + name: "an unstarted fork selecting another Claude home", + unstartedFork: true, + selected: claudeOtherHome, + expected: false, + }, + { name: "for a provider without /compact", slashCommands: [], expected: false }, + { + name: "after Claude's resume dialog was answered Don't ask again", + dismissed: true, + expected: false, + }, + { name: "while the queue is held", heldQueue: true, expected: false }, + { name: "while a request waits for an answer", pendingRequest: true, expected: false }, + { name: "while the thread is working", threadIdle: false, expected: false }, + { name: "with no conversation but /compact", onlyCompactMessages: true, expected: false }, + { name: "for a typed /compact", text: "/compact keep the plan", expected: false }, + ] as const)("$name", ({ name: _name, expected, ...overrides }) => { + expect(shouldCompactBeforeSend(sendInput(overrides as Parameters[0]))).toBe( + expected, + ); + }); +}); diff --git a/packages/client-runtime/src/state/contextWindow.ts b/packages/client-runtime/src/state/contextWindow.ts new file mode 100644 index 0000000000000..66c48807465af --- /dev/null +++ b/packages/client-runtime/src/state/contextWindow.ts @@ -0,0 +1,319 @@ +import { + resolveIdleCompaction, + type ModelSelection, + type OrchestrationV2ProviderTurn, + type OrchestrationV2ProviderTurnTokenUsage, + type OrchestrationV2ProviderThread, + type ServerProvider, + type ServerSettings, + type ThreadTokenUsageSnapshot, +} from "@t3tools/contracts"; +import { + CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + COMPACTION_SUGGESTION_MIN_TOKENS, + isClaudeResumeCompactionQuestion, +} from "@t3tools/shared/claudeCompaction"; +import * as DateTime from "effect/DateTime"; + +import { resolvePendingHandoff } from "../handoff.ts"; +import type { ThreadStructure, ThreadStructureTurnItem } from "./models.ts"; + +// Fallback TTL: Claude's 1h cache plus slack. +const CLAUDE_RESUME_COMPACTION_FALLBACK_MS = 70 * 60_000; + +// A typed `/compact`, with or without instructions, is already the compaction. Queueing a +// bare one ahead of it would run an uninstructed compaction first and charge for two. +export const COMPACT_COMMAND_PATTERN = /^\/compact(?:\s|$)/i; + +function asFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +type NullableContextWindowUsage = { + readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key] + ? Exclude | null + : ThreadTokenUsageSnapshot[Key]; +}; + +export type ContextWindowSnapshot = NullableContextWindowUsage & { + readonly remainingTokens: number | null; + readonly usedPercentage: number | null; + readonly remainingPercentage: number | null; + readonly updatedAt: string; +}; + +/** Newest report by timestamp; snapshots order turns by provider thread. */ +export function latestProviderTurnTokenUsage( + turns: ReadonlyArray>, + projection?: Pick | null, + providerThread?: Pick | null, +): OrchestrationV2ProviderTurnTokenUsage | null { + let latest: OrchestrationV2ProviderTurnTokenUsage | null = null; + for (const { tokenUsage } of turns) { + if (tokenUsage === undefined) continue; + // Ties go to the later entry, since live upserts append. + if (latest === null || Date.parse(tokenUsage.updatedAt) >= Date.parse(latest.updatedAt)) { + latest = tokenUsage; + } + } + if (latest !== null || projection == null || providerThread?.contextUsage != null) return latest; + const forkedFrom = projection.thread.forkedFrom; + if (forkedFrom?.type !== "run") return null; + return ( + projection.contextTransfers.find( + (transfer) => + transfer.type === "fork" && + transfer.targetThreadId === projection.thread.id && + transfer.sourceThreadId === forkedFrom.threadId && + transfer.sourcePoint.runId === forkedFrom.runId && + transfer.sourceProviderInstanceId === projection.thread.modelSelection.instanceId && + (transfer.status === "pending" || transfer.resolution?.strategy === "native_fork"), + )?.sourceTokenUsage ?? null + ); +} + +export function deriveLatestContextWindowSnapshot( + entries: ReadonlyArray<{ + readonly item: ThreadStructureTurnItem; + }>, + liveUsage?: OrchestrationV2ProviderTurnTokenUsage | null, + providerThread?: Pick | null, +): ContextWindowSnapshot | null { + if (liveUsage != null) { + const usedTokens = Math.max(0, liveUsage.usedTokens); + const maxTokens = liveUsage.maxTokens ?? null; + const usedPercentage = + maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; + const remainingTokens = + maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null; + const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null; + return { + usedTokens, + totalProcessedTokens: null, + maxTokens, + remainingTokens, + usedPercentage, + remainingPercentage, + inputTokens: liveUsage.inputTokens ?? null, + cachedInputTokens: liveUsage.cachedInputTokens ?? null, + outputTokens: liveUsage.outputTokens ?? null, + reasoningOutputTokens: liveUsage.reasoningOutputTokens ?? null, + lastUsedTokens: null, + lastInputTokens: null, + lastCachedInputTokens: null, + lastOutputTokens: null, + lastReasoningOutputTokens: null, + toolUses: null, + durationMs: null, + compactsAutomatically: true, + autoCompactThreshold: liveUsage.autoCompactThreshold ?? null, + cost: null, + updatedAt: liveUsage.updatedAt, + }; + } + const providerUsage = providerThread?.contextUsage; + const providerUsageUpdatedAt = providerThread?.updatedAt; + if ( + providerUsage !== null && + providerUsage !== undefined && + providerUsageUpdatedAt !== undefined + ) { + const maxTokens = asFiniteNumber(providerUsage.maxTokens); + const usedTokens = providerUsage.usedTokens; + const usedPercentage = + maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; + return { + usedTokens, + totalProcessedTokens: asFiniteNumber(providerUsage.totalProcessedTokens), + maxTokens, + remainingTokens: maxTokens === null ? null : Math.max(0, Math.round(maxTokens - usedTokens)), + usedPercentage, + remainingPercentage: usedPercentage === null ? null : Math.max(0, 100 - usedPercentage), + inputTokens: asFiniteNumber(providerUsage.inputTokens), + cachedInputTokens: asFiniteNumber(providerUsage.cachedInputTokens), + outputTokens: asFiniteNumber(providerUsage.outputTokens), + reasoningOutputTokens: asFiniteNumber(providerUsage.reasoningOutputTokens), + lastUsedTokens: asFiniteNumber(providerUsage.lastUsedTokens), + lastInputTokens: asFiniteNumber(providerUsage.lastInputTokens), + lastCachedInputTokens: asFiniteNumber(providerUsage.lastCachedInputTokens), + lastOutputTokens: asFiniteNumber(providerUsage.lastOutputTokens), + lastReasoningOutputTokens: asFiniteNumber(providerUsage.lastReasoningOutputTokens), + toolUses: asFiniteNumber(providerUsage.toolUses), + durationMs: asFiniteNumber(providerUsage.durationMs), + compactsAutomatically: providerUsage.compactsAutomatically ?? null, + cost: providerUsage.cost ?? null, + autoCompactThreshold: providerUsage.autoCompactThreshold ?? null, + updatedAt: DateTime.formatIso(providerUsageUpdatedAt), + }; + } + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (!entry || entry.item.type !== "compaction") { + continue; + } + const payload = entry.item; + const usedTokens = asFiniteNumber(payload.afterTokenCount); + if (usedTokens === null || usedTokens < 0) { + continue; + } + + const maxTokens = null; + const usedPercentage = + maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; + const remainingTokens = + maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null; + const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null; + + return { + usedTokens, + totalProcessedTokens: asFiniteNumber(payload.beforeTokenCount), + maxTokens, + remainingTokens, + usedPercentage, + remainingPercentage, + inputTokens: null, + cachedInputTokens: null, + outputTokens: null, + reasoningOutputTokens: null, + lastUsedTokens: null, + lastInputTokens: null, + lastCachedInputTokens: null, + lastOutputTokens: null, + lastReasoningOutputTokens: null, + toolUses: null, + durationMs: null, + compactsAutomatically: true, + autoCompactThreshold: null, + cost: null, + updatedAt: DateTime.formatIso(payload.startedAt ?? payload.updatedAt), + }; + } + + return null; +} + +export function hasDismissedResumeCompaction( + activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>, +): boolean { + return activities.some((activity) => { + if (activity.kind !== "user-input.resolved") return false; + const payload = activity.payload; + if (!payload || typeof payload !== "object") return false; + const answers = (payload as { readonly answers?: unknown }).answers; + if (!answers || typeof answers !== "object" || Array.isArray(answers)) return false; + + return Object.entries(answers).some( + ([question, answer]) => + isClaudeResumeCompactionQuestion(question) && + answer === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + ); + }); +} + +export function shouldOfferResumeCompaction(input: { + readonly provider: string | null | undefined; + readonly usedTokens: number | null | undefined; + readonly updatedAt: string | null | undefined; + readonly promptCacheTtlMs?: number | undefined; + readonly now: string; +}): boolean { + if ( + input.provider !== "claudeAgent" || + (input.usedTokens ?? 0) < COMPACTION_SUGGESTION_MIN_TOKENS + ) { + return false; + } + + const updatedAt = Date.parse(input.updatedAt ?? ""); + const now = Date.parse(input.now); + return ( + Number.isFinite(updatedAt) && + Number.isFinite(now) && + now - updatedAt >= (input.promptCacheTtlMs ?? CLAUDE_RESUME_COMPACTION_FALLBACK_MS) + ); +} + +/** + * Whether a plain send into an idle thread compacts it first, as web's composer does under + * "When you return". Web reads the same state for its "Compact and send" button. A send that + * hands off to another conversation starts that one fresh, so only the resumed one counts. + */ +export function shouldCompactBeforeSend(input: { + readonly projection: Pick< + ThreadStructure, + | "thread" + | "contextTransfers" + | "providerThreads" + | "providerTurns" + | "runtimeRequests" + | "runs" + | "visibleTurnItems" + >; + readonly visibleTurnItems: ReadonlyArray<{ readonly item: ThreadStructureTurnItem }>; + readonly selection: ModelSelection; + readonly providers: ReadonlyArray; + readonly settings: Pick< + ServerSettings, + "providerInstances" | "compactIdleThreads" | "idleCompaction" + >; + readonly text: string; + readonly threadIdle: boolean; + readonly now: string; +}): boolean { + const { projection } = input; + const provider = input.providers.find((entry) => entry.instanceId === input.selection.instanceId); + if ( + provider === undefined || + !input.threadIdle || + COMPACT_COMMAND_PATTERN.test(input.text) || + resolveIdleCompaction(input.settings, provider.instanceId) === "off" || + !provider.slashCommands.some((command) => command.name === "compact") || + resolvePendingHandoff(projection, input.selection, input.providers) !== null || + projection.runtimeRequests.some((request) => request.status === "pending") || + projection.runs.some((run) => run.status === "queued" && run.queueHeld === true) || + (!input.visibleTurnItems.some( + ({ item }) => + item.type === "user_message" && + (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0), + ) && + !projection.runs.some( + (run) => + run.status !== "rolled_back" && + run.startedAt !== null && + !input.visibleTurnItems.some( + ({ item }) => item.type === "user_message" && item.runId === run.id, + ), + )) || + hasDismissedResumeCompaction( + projection.runtimeRequests + .filter((request) => request.kind === "user_input" && request.status === "resolved") + .map((request) => ({ kind: "user-input.resolved", payload: { answers: request.answers } })), + ) + ) { + return false; + } + const activeProviderThreadId = projection.thread.activeProviderThreadId; + const providerThread = projection.providerThreads.find( + (thread) => thread.id === activeProviderThreadId, + ); + const liveUsage = latestProviderTurnTokenUsage( + projection.providerTurns.filter((turn) => turn.providerThreadId === activeProviderThreadId), + input.selection.instanceId === projection.thread.modelSelection.instanceId ? projection : null, + providerThread, + ); + const contextWindow = deriveLatestContextWindowSnapshot( + input.visibleTurnItems, + liveUsage, + providerThread, + ); + return ( + contextWindow !== null && + shouldOfferResumeCompaction({ + provider: provider.driver, + usedTokens: contextWindow.usedTokens, + updatedAt: contextWindow.updatedAt, + promptCacheTtlMs: liveUsage?.promptCacheTtlMs, + now: input.now, + }) + ); +} diff --git a/packages/client-runtime/src/state/fileMetadata.test.ts b/packages/client-runtime/src/state/fileMetadata.test.ts index c9536650e3a89..ff6a817dee429 100644 --- a/packages/client-runtime/src/state/fileMetadata.test.ts +++ b/packages/client-runtime/src/state/fileMetadata.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "@effect/vitest"; +import { describe, expect, it, vi } from "@effect/vitest"; import { EnvironmentId, ThreadId, @@ -17,9 +17,10 @@ import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as Schema from "effect/Schema"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import { Atom, AtomRegistry } from "effect/reactivity"; -import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; +import { AVAILABLE_CONNECTION_STATE, ConnectionTarget } from "../connection/model.ts"; import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import type { RpcSession } from "../rpc/session.ts"; @@ -56,13 +57,24 @@ const makeHarness = Effect.fnUntraced(function* () { phase: "connected" as const, }); const supervisor = { - target: { environmentId: ENVIRONMENT_ID }, + // Coder: the gateway target stands in for upstream's bearer target. + target: new ConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Test", + httpBaseUrl: "http://127.0.0.1", + wsBaseUrl: "ws://127.0.0.1", + }), session, state, } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]; const otherSupervisor = { ...supervisor, - target: { environmentId: OTHER_ENVIRONMENT_ID }, + target: new ConnectionTarget({ + environmentId: OTHER_ENVIRONMENT_ID, + label: "Test", + httpBaseUrl: "http://127.0.0.1", + wsBaseUrl: "ws://127.0.0.1", + }), session: yield* SubscriptionRef.make(Option.some(makeSession())), } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]; const getSupervisor = (environmentId: EnvironmentId) => @@ -118,6 +130,42 @@ const makeHarness = Effect.fnUntraced(function* () { }); describe("file metadata", () => { + it.effect("refreshes expired mounted misses while retaining known thread entries", () => + Effect.gen(function* () { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + yield* Effect.addFinalizer(() => Effect.sync(() => vi.useRealTimers())); + const h = yield* makeHarness(); + const threadId = ThreadId.make("new-directory-thread"); + h.registry.mount(h.atoms.retainThread({ environmentId: ENVIRONMENT_ID, threadId })); + const path = "/home/user/games/hl1"; + h.registry.mount(h.atom(path, ENVIRONMENT_ID, threadId)); + h.values.set("/workspace/known", { kind: "file", byteLength: 5 }); + expect(yield* h.read(path, ENVIRONMENT_ID, threadId)).toBeNull(); + yield* h.read("/workspace/known", ENVIRONMENT_ID, threadId); + h.values.set(path, { kind: "directory" }); + h.registry.refresh(h.atom(path, ENVIRONMENT_ID, threadId)); + expect(yield* h.read(path, ENVIRONMENT_ID, threadId)).toBeNull(); + h.failure.denied = true; + yield* TestClock.adjust(60_001); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(60_000)); + expect(yield* h.read(path, ENVIRONMENT_ID, threadId).pipe(Effect.flip)).toBeInstanceOf( + EnvironmentAuthorizationError, + ); + h.failure.denied = false; + yield* TestClock.adjust(60_001); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(60_000)); + expect(yield* h.read(path, ENVIRONMENT_ID, threadId)).toEqual({ kind: "directory" }); + h.registry.refresh(h.atom("/workspace/known", ENVIRONMENT_ID, threadId)); + expect(yield* h.read("/workspace/known", ENVIRONMENT_ID, threadId)).toEqual({ + kind: "file", + byteLength: 5, + }); + yield* TestClock.adjust(60_001); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(60_000)); + expect(h.batches).toEqual([[path], ["/workspace/known"], [path], [path]]); + }).pipe(Effect.scoped), + ); + it.effect("does not seed a different path from a literal name with a numeric suffix", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/packages/client-runtime/src/state/fileMetadata.ts b/packages/client-runtime/src/state/fileMetadata.ts index d079f08c00bdf..5d20d3188909a 100644 --- a/packages/client-runtime/src/state/fileMetadata.ts +++ b/packages/client-runtime/src/state/fileMetadata.ts @@ -11,10 +11,11 @@ import { normalizeProjectPathForComparison, resolveWorkspaceFilePath } from "@t3 import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; import * as Request from "effect/Request"; import * as RequestResolver from "effect/RequestResolver"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { Atom, AtomRegistry } from "effect/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -200,8 +201,10 @@ export function createFileMetadataAtoms( thread.sessions.set(session.value, retained); } const retainedEntry = retained?.get(input.path); - if (retainedEntry) return retainedEntry.value; const now = yield* Clock.currentTimeMillis; + // Agents mention paths before creating them, so misses must still expire within a thread. + if (retainedEntry && (retainedEntry.value !== null || retainedEntry.expires > now)) + return retainedEntry.value; const cached = knownEntry(session.value, input.path, now); if (cached) { retained?.set(input.path, cached); @@ -225,14 +228,29 @@ export function createFileMetadataAtoms( input: FileMetadataInput; }) => revision(environmentId, input.path), }); + const refreshMissing = Atom.family((query: ReturnType) => { + const retry = query.pipe(Atom.withRefresh(METADATA_STALE_TIME_MS), Atom.setIdleTTL(0)); + return Atom.transform( + query, + (get) => { + const result = get(query); + return Option.exists(AsyncResult.value(result), (value) => value === null) + ? get(retry) + : result; + }, + { initialValueTarget: query }, + ).pipe(Atom.setIdleTTL(0)); + }); const metadata = (target: { environmentId: EnvironmentId; input: { path: string; threadId?: ThreadId }; }) => - metadataFamily({ - ...target, - input: { ...target.input, path: normalizeProjectPathForComparison(target.input.path) }, - }); + refreshMissing( + metadataFamily({ + ...target, + input: { ...target.input, path: normalizeProjectPathForComparison(target.input.path) }, + }), + ); const rememberEntries = Effect.fnUntraced(function* ( cwd: string, diff --git a/packages/client-runtime/src/state/git.ts b/packages/client-runtime/src/state/git.ts index ddce3ca4da8c8..18940290a997a 100644 --- a/packages/client-runtime/src/state/git.ts +++ b/packages/client-runtime/src/state/git.ts @@ -3,10 +3,12 @@ import { Atom } from "effect/reactivity"; import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import * as Persistence from "../platform/persistence.ts"; import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; +import { invalidateCachedVcsRefs } from "./vcsRefInvalidation.ts"; export function createGitEnvironmentAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { return { pullRequestResolution: createEnvironmentRpcQueryAtomFamily(runtime, { @@ -18,6 +20,8 @@ export function createGitEnvironmentAtoms( tag: WS_METHODS.gitPreparePullRequestThread, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: ({ environmentId, input }, registry) => + invalidateCachedVcsRefs(registry, { environmentId, cwd: input.cwd }), }), }; } diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index a52ad0dece8a0..59b34a66171a1 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -1,6 +1,7 @@ import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS, + type EnvironmentId, type OrchestrationGetTurnDiffResult, } from "@t3tools/contracts"; import * as Stream from "effect/Stream"; @@ -15,10 +16,15 @@ import { type QueryAnswerHandler, } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import type { EnvironmentShellState } from "./shell.ts"; export function createOrchestrationEnvironmentAtoms( runtime: Atom.AtomRuntime, options: { + /** Re-checks `threadTitles` when one of their threads leaves the shell. */ + readonly shellState?: ( + environmentId: EnvironmentId, + ) => Atom.Atom>; /** Runs on each turn-range and full-thread diff the server answers. */ readonly onCheckpointDiffAnswer?: QueryAnswerHandler< { readonly ignoreWhitespace?: boolean }, @@ -30,7 +36,24 @@ export function createOrchestrationEnvironmentAtoms( options.onCheckpointDiffAnswer === undefined ? {} : { onAnswer: options.onCheckpointDiffAnswer }; + const shellState = options.shellState; return { + threadTitles: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:thread-titles", + tag: ORCHESTRATION_V2_WS_METHODS.getThreadTitles, + staleTimeMs: 0, + idleTtlMs: 0, + // Deleting a thread the shell already left out keeps these ids, so the query would not rerun. + // A removal can also be an archive, so it only prompts a re-check. + refreshTrigger: ({ environmentId, input }) => { + if (shellState === undefined) return undefined; + const state = shellState(environmentId); + return Atom.make((get) => { + const { removedAt } = get(state); + return Math.max(-1, ...input.threadIds.map((threadId) => removedAt.get(threadId) ?? -1)); + }); + }, + }), // Coder: upstream's authenticated HTTP transcript fetch, read over helper stdio instead. threadTranscript: createEnvironmentRpcCommand(runtime, { label: "environment-data:orchestration:thread-transcript", diff --git a/packages/client-runtime/src/state/projectCommands.test.ts b/packages/client-runtime/src/state/projectCommands.test.ts index 727dd87ff6864..3370ef2b5a5c8 100644 --- a/packages/client-runtime/src/state/projectCommands.test.ts +++ b/packages/client-runtime/src/state/projectCommands.test.ts @@ -11,7 +11,7 @@ import { WS_METHODS, } from "@t3tools/contracts"; import { vi } from "vite-plus/test"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; @@ -21,7 +21,7 @@ import * as Schema from "effect/Schema"; import * as SubscriptionRef from "effect/SubscriptionRef"; import * as Stream from "effect/Stream"; import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; -import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; +import { AVAILABLE_CONNECTION_STATE, ConnectionTarget } from "../connection/model.ts"; import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; @@ -53,7 +53,13 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { missing: false, }; const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ - target: { environmentId: ENVIRONMENT_ID }, + // Coder: the gateway target stands in for upstream's bearer target. + target: new ConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Test", + httpBaseUrl: "http://127.0.0.1", + wsBaseUrl: "ws://127.0.0.1", + }), session: yield* SubscriptionRef.make( Option.some({ client: { @@ -107,11 +113,8 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), } as EnvironmentRegistry.EnvironmentRegistry["Service"]), Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: (_algorithm, data) => Effect.succeed(data), - }), + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size)), ), ), ); diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 61e8a2ca09aef..54f120e573c05 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -5,7 +5,7 @@ import { type ScopedProjectRef, WS_METHODS, } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; @@ -73,7 +73,7 @@ export class ScratchProjectNotLoadedError extends Schema.TaggedError( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, options: { readonly projectAtom: (ref: ScopedProjectRef) => Atom.Atom; }, diff --git a/packages/client-runtime/src/state/projectRemoval.ts b/packages/client-runtime/src/state/projectRemoval.ts index 09c65a772e689..2225e5758bd2d 100644 --- a/packages/client-runtime/src/state/projectRemoval.ts +++ b/packages/client-runtime/src/state/projectRemoval.ts @@ -1,7 +1,9 @@ +import { isListedThreadLineage } from "@t3tools/contracts"; + import type { EnvironmentProject, EnvironmentThreadShell } from "./models.ts"; export function planProjectRemoval< - Thread extends Pick, + Thread extends Pick, >(options: { readonly members: ReadonlyArray< Pick & { @@ -20,7 +22,10 @@ export function planProjectRemoval< const projectThreads = threads.filter((thread) => memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); - const threadCount = projectThreads.length + (options.unloadedThreadCount ?? 0); + // Side chats and subagents go with their parent but never show in thread lists. + const threadCount = + projectThreads.filter((thread) => isListedThreadLineage(thread.lineage)).length + + (options.unloadedThreadCount ?? 0); const isWholeGroup = members.length === groupMemberCount; const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project"; const singleMember = members.length === 1 ? members[0]! : null; diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index b6d5ef4196053..bfc4757855d25 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -5,13 +5,13 @@ import { WS_METHODS, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as PubSub from "effect/PubSub"; import * as Deferred from "effect/Deferred"; import * as Latch from "effect/Latch"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; @@ -32,7 +32,7 @@ import { } from "./pullRequests.ts"; import { executeAtomQuery } from "./runtime.ts"; -class MutationRefused extends Data.TaggedError("MutationRefused") {} +class MutationRefused extends Schema.TaggedError()("MutationRefused", {}) {} // Coder: one connection target; upstream's cross-environment routing tests (shared probes, // alternates, routed viewed marks and writes) are not carried with that routing. @@ -51,7 +51,7 @@ function session(client: WsRpcProtocolClient): RpcSession { client[WS_METHODS.pullRequestsInvalidate] ?? (() => Effect.void), }, initialConfig: client[WS_METHODS.serverGetConfig]?.({}).pipe(Effect.orDie) ?? Effect.never, - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), @@ -92,7 +92,7 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => Effect.sync(() => registry.dispose()), ); - return { runtime, atoms, registry }; + return { runtime, atoms, registry, supervisor }; }); it.effect("keeps concurrent diff file reads on different hosts separate", () => @@ -493,20 +493,71 @@ it.effect("shares checks reads across reference property order", () => it.effect("keeps live detail reads separate from reads that allow stale data", () => Effect.scoped( Effect.gen(function* () { + let reads = 0; + let hostStamp: number | undefined; + let hold = false; + const readStarted = yield* Latch.make(); + const releaseRead = yield* Latch.make(); const client = { [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.never, [WS_METHODS.pullRequestsDetail]: (input: { allowStale?: boolean }) => - Effect.succeed({ title: input.allowStale === false ? "latest checks" : "cached checks" }), + Effect.gen(function* () { + reads++; + if (hold) { + yield* readStarted.open; + yield* releaseRead.await; + } + return { + title: input.allowStale === false ? "latest checks" : "cached checks", + ...(hostStamp === undefined ? {} : { observedAt: hostStamp }), + }; + }), } as unknown as WsRpcProtocolClient; - const { atoms, registry } = yield* makeTestRuntime(client); + const { atoms, registry, supervisor } = yield* makeTestRuntime(client); const target = { environmentId: TARGET.environmentId, input: { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 1 }, }; const cached = atoms.detail(target); const live = atoms.detail({ ...target, input: { ...target.input, allowStale: false } }); - expect((yield* AtomRegistry.getResult(registry, cached)).title).toBe("cached checks"); + const first = yield* AtomRegistry.getResult(registry, cached); + expect(first.title).toBe("cached checks"); + expect(first.observedAt).toBeUndefined(); + const reading = atoms.detailReadVersion(target); + const firstVersion = registry.get(reading); + expect(firstVersion).toBeGreaterThan(0); expect((yield* AtomRegistry.getResult(registry, live)).title).toBe("latest checks"); + expect(reads).toBe(2); + hold = true; + registry.refresh(cached); + yield* readStarted.await; + expect(registry.get(reading)).toBe(firstVersion); + yield* releaseRead.open; + expect(yield* AtomRegistry.getResult(registry, cached, { suspendOnWaiting: true })).toBe( + first, + ); + const settledVersion = registry.get(reading); + expect(settledVersion).toBeGreaterThan(firstVersion); + hold = false; + + // Coder: no offline query store (mobile-only persistence), so an offline refresh has no + // cached answer to serve; upstream's offline step is not carried. + yield* SubscriptionRef.update(supervisor.state, (state) => ({ + ...state, + phase: "connected" as const, + })); + yield* Effect.yieldNow; + registry.refresh(cached); + const reread = yield* AtomRegistry.getResult(registry, cached, { suspendOnWaiting: true }); + expect(reread).toBe(first); + expect(reread.observedAt).toBeUndefined(); + expect(registry.get(reading)).toBeGreaterThan(settledVersion); + + hostStamp = 27; + registry.refresh(cached); + expect( + (yield* AtomRegistry.getResult(registry, cached, { suspendOnWaiting: true })).observedAt, + ).toBe(hostStamp); }), ), ); diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index b3ebdce81321f..f2bd8d9dd04f3 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -188,15 +188,44 @@ export function createPullRequestEnvironmentAtoms( refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }), ); + const detailAnswerAtoms = Atom.family((_key: string) => + Atom.make<{ readonly value: PullRequestDetail; readonly version: number } | null>(null).pipe( + Atom.setIdleTTL(5 * 60_000), + ), + ); + const detailAnswers = normalizePullRequestQuery((target) => + detailAnswerAtoms(JSON.stringify(target)), + ); const detail = writableQueryFamily( createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:detail", tag: WS_METHODS.pullRequestsDetail, staleTimeMs: 60_000, refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), - ...(onAnswer.detail === undefined ? {} : { onAnswer: onAnswer.detail }), + onAnswer: (target, value, registry) => + Effect.sync(() => { + // Cache reads bypass onAnswer, and server freshness stays separate from local readbacks. + registry.update(detailAnswers(target), (previous) => ({ + value, + version: (previous?.version ?? 0) + 1, + })); + }).pipe(Effect.andThen(() => onAnswer.detail?.(target, value, registry) ?? Effect.void)), }), ); + const detailReadVersions = Atom.family((source: ReturnType) => + Atom.family((answer: ReturnType) => + Atom.make((get) => { + const result = get(source); + const latest = get(answer); + return result._tag === "Success" && !result.waiting && result.value === latest?.value + ? latest.version + : Option.getOrElse(get.self(), () => 0); + }).pipe(Atom.setIdleTTL(5 * 60_000)), + ), + ); + const detailReadVersion = normalizePullRequestQuery((target) => + detailReadVersions(detail(target))(detailAnswers(target)), + ); const preview = writableQueryFamily( createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:preview", @@ -325,6 +354,7 @@ export function createPullRequestEnvironmentAtoms( staleTimeMs: 5 * 60_000, }), detail, + detailReadVersion, preview, previewCard, checks: normalizePullRequestQuery( diff --git a/packages/client-runtime/src/state/review.ts b/packages/client-runtime/src/state/review.ts index b7b99b9b2ae3f..58601abd0ea8c 100644 --- a/packages/client-runtime/src/state/review.ts +++ b/packages/client-runtime/src/state/review.ts @@ -35,6 +35,29 @@ export function createReviewEnvironmentAtoms( label: "environment-data:review:diff-preview", tag: WS_METHODS.reviewGetDiffPreview, staleTimeMs: 5_000, + execute: Effect.fn("review.diffPreview")(function* ( + input: ReviewDiffPreviewInput, + emit: (value: ReviewDiffPreviewResult) => Effect.Effect, + previous: Option.Option, + ) { + const hasCompletePreview = Option.exists(previous, (preview) => !preview.incomplete); + if ( + !hasCompletePreview && + input.sourceKind && + !input.file && + !input.commitSha && + !input.trackedOnly + ) { + const tracked = yield* request(WS_METHODS.reviewGetDiffPreview, { + ...input, + trackedOnly: true, + }); + // Older servers ignore trackedOnly and already return the complete preview. + if (!tracked.incomplete) return tracked; + yield* emit(tracked); + } + return yield* request(WS_METHODS.reviewGetDiffPreview, input); + }), ...(options.onDiffPreviewAnswer === undefined ? {} : { onAnswer: options.onDiffPreviewAnswer }), }); // A file patch is a slice of the comparison its preview generated, so a newer preview diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 4a8c34542a05c..2dcd60f5e0ee1 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; import { EnvironmentId, + ORCHESTRATION_V2_WS_METHODS, + ThreadId, + type ReviewDiffPreviewInput, type TerminalAttachInput, type TerminalAttachStreamEvent, type TerminalMetadataStreamEvent, @@ -8,6 +11,7 @@ import { WS_METHODS, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -18,6 +22,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import type { HttpClient } from "effect/http"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { @@ -32,6 +37,9 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; import type * as RpcSession from "../rpc/session.ts"; +import { createOrchestrationEnvironmentAtoms } from "./orchestration.ts"; +import { createReviewEnvironmentAtoms } from "./review.ts"; +import type { EnvironmentShellState } from "./shell.ts"; import { createTerminalEnvironmentAtoms } from "./terminal.ts"; import { createAtomCommandScheduler, @@ -88,6 +96,7 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" refreshTrigger, idleTtlMs, initialState, + rpcSession, isFinal, onAnswer, }: { @@ -96,10 +105,13 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" readonly isFinal?: (value: A) => boolean; readonly idleTtlMs?: number; readonly initialState?: SupervisorConnectionState; + readonly rpcSession?: RpcSession.RpcSession; } = {}, ) { const supervisorState = yield* SubscriptionRef.make(initialState ?? queryConnectionState()); - const supervisorSession = yield* SubscriptionRef.make(Option.some(QUERY_RPC_SESSION)); + const supervisorSession = yield* SubscriptionRef.make( + Option.some(rpcSession ?? QUERY_RPC_SESSION), + ); const streamGeneration = yield* SubscriptionRef.make(0); const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: QUERY_ENVIRONMENT, @@ -174,6 +186,62 @@ const mountEnvironmentQuery = Effect.fn("TestEnvironmentQuery.mount")(function* return registry; }); +it.effect("keeps complete review metadata visible while a refresh is held", () => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let fullReads = 0; + const rpcSession = { + client: { + [WS_METHODS.reviewGetDiffPreview]: (input: ReviewDiffPreviewInput) => + Effect.gen(function* () { + if (!input.trackedOnly && ++fullReads > 1) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + return { + cwd: input.cwd, + generatedAt: DateTime.makeUnsafe(0), + ...(input.trackedOnly ? { incomplete: true } : {}), + sources: [ + { + id: "working-tree", + kind: "working-tree" as const, + title: "Uncommitted", + baseRef: "HEAD", + headRef: null, + diff: "", + diffHash: String(fullReads), + truncated: false, + patchesDeferred: true, + files: (input.trackedOnly ? ["tracked"] : ["tracked", "untracked"]).map( + (path) => ({ path, previousPath: null, additions: 1, deletions: 0 }), + ), + }, + ], + }; + }), + }, + } as unknown as RpcSession.RpcSession; + const harness = yield* makeEnvironmentQueryHarness(Effect.void, { rpcSession }); + const atom = createReviewEnvironmentAtoms(harness.runtime).diffPreview({ + environmentId: QUERY_ENVIRONMENT.environmentId, + input: { cwd: "/project", sourceKind: "working-tree", metadataOnly: true }, + }); + const registry = yield* mountEnvironmentQuery(atom); + expect((yield* readEnvironmentQuery(registry, atom)).sources[0]!.files).toHaveLength(2); + registry.refresh(atom); + yield* Deferred.await(started); + yield* Effect.sync(() => { + const value = AsyncResult.getOrElse(registry.get(atom), () => null); + expect(value?.incomplete).not.toBe(true); + expect(value?.sources[0]!.files).toHaveLength(2); + }).pipe(Effect.ensuring(Deferred.succeed(release, undefined))); + expect((yield* readEnvironmentQuery(registry, atom)).sources[0]!.diffHash).toBe("2"); + }), + ), +); describe("finite environment subscription lifecycle", () => { it.effect( "exposes early values and settles on the terminal value without waiting for a reconnect", @@ -871,6 +939,64 @@ describe("environment query lifecycle", () => { }), ), ); + + it.effect("re-checks thread titles when one of their threads leaves the shell", () => + Effect.scoped( + Effect.gen(function* () { + const requests = yield* Queue.unbounded>>(); + const harness = yield* makeEnvironmentQueryHarness(Effect.void, { + rpcSession: { + client: { + [ORCHESTRATION_V2_WS_METHODS.getThreadTitles]: () => + Effect.gen(function* () { + const answer = yield* Deferred.make>(); + yield* Queue.offer(requests, answer); + return yield* Deferred.await(answer); + }), + }, + } as unknown as RpcSession.RpcSession, + }); + const shellState = Atom.make>({ + removedAt: new Map(), + }).pipe(Atom.keepAlive); + const removed = (entries: ReadonlyArray) => ({ + removedAt: new Map(entries.map(([id, sequence]) => [ThreadId.make(id), sequence])), + }); + // Thread titles never use the HTTP client. + const runtime = harness.runtime as unknown as Atom.AtomRuntime< + EnvironmentRegistry.EnvironmentRegistry | HttpClient.HttpClient + >; + const atom = createOrchestrationEnvironmentAtoms(runtime, { + shellState: () => shellState, + }).threadTitles({ + environmentId: QUERY_ENVIRONMENT.environmentId, + input: { threadIds: [ThreadId.make("fork")] }, + }); + const registry = yield* mountEnvironmentQuery(atom); + const settled: Array> = []; + const unsubscribe = registry.subscribe(atom, (result) => { + if (AsyncResult.isSuccess(result) && !result.waiting) settled.push(result.value); + }); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* Deferred.succeed(yield* Queue.take(requests), { fork: "Fork" }); + expect(yield* readEnvironmentQuery(registry, atom)).toEqual({ fork: "Fork" }); + + registry.set(shellState, removed([["other", 5]])); + expect(registry.get(atom).waiting).toBe(false); + + // Archiving and then deleting the fork both remove it from the shell. + registry.set(shellState, removed([["fork", 6]])); + const beforeDelete = yield* Queue.take(requests); + registry.set(shellState, removed([["fork", 7]])); + const afterDelete = yield* Queue.take(requests); + yield* Deferred.succeed(beforeDelete, { fork: "Fork" }); + yield* Deferred.succeed(afterDelete, {}); + expect(yield* readEnvironmentQuery(registry, atom)).toEqual({}); + expect(settled).toEqual([{ fork: "Fork" }, {}]); + }), + ), + ); }); describe("executeAtomQuery", () => { diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index ac4ede0fae1ba..481aa0518a2f8 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -92,6 +92,7 @@ interface EnvironmentQueryAtomOptions extends Omit< readonly execute: ( input: Input, emit: (value: A) => Effect.Effect, + previous: Option.Option, ) => Effect.Effect; // Coder: no `persistence` option; upstream's offline query store serves only mobile. readonly staleTimeMs?: number; @@ -714,7 +715,14 @@ export function createEnvironmentQueryAtomFamily( ? runInEnvironment( target.environmentId, options - .execute(target.input, emit) + .execute( + target.input, + emit, + Option.flatMap( + get.self>(), + AsyncResult.value, + ), + ) .pipe(pendingOnceSessionReplaced(session.value)), ).pipe( Effect.map(share), @@ -839,6 +847,7 @@ export function createEnvironmentRpcQueryAtomFamily< readonly execute?: ( input: EnvironmentRpcInput, emit: (value: EnvironmentRpcSuccess) => Effect.Effect, + previous: Option.Option>, ) => Effect.Effect< EnvironmentRpcSuccess, EnvironmentRpcFailure | EnvironmentRpcUnavailableError, @@ -868,7 +877,8 @@ export function createEnvironmentRpcQueryAtomFamily< execute: ( input: EnvironmentRpcInput, emit: (value: EnvironmentRpcSuccess) => Effect.Effect, - ) => options.execute?.(input, emit) ?? request(options.tag, input), + previous: Option.Option>, + ) => options.execute?.(input, emit, previous) ?? request(options.tag, input), }); } diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 3b9b210ca95fd..a7a82310835f9 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -56,7 +56,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.succeed({ shellResumeCompletionMarker: true } as never), - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts index 60904cce0c839..1b427019d23ac 100644 --- a/packages/client-runtime/src/state/shellSnapshotHttp.ts +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -11,5 +11,5 @@ export class ShellSnapshotLoader extends Context.Reference<{ options?: { readonly omitIdleSubagents?: boolean; readonly omitOlderSettled?: boolean }, ) => Effect.Effect>; }>("@t3tools/client-runtime/state/shellSnapshotHttp/ShellSnapshotLoader", { - defaultValue: () => ({ load: () => Effect.succeed(Option.none()) }), + defaultValue: () => ({ load: () => Effect.succeedNone }), }) {} diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts index 32689ecc59e5d..ee2ae19162463 100644 --- a/packages/client-runtime/src/state/terminalSession.test.ts +++ b/packages/client-runtime/src/state/terminalSession.test.ts @@ -266,10 +266,14 @@ describe("terminal session reducers", () => { snapshot: BASE_SNAPSHOT, }); let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + const attachedGeneration = state.output.generation; + // A socket reconnect resubscribes the same stream, so the server's new snapshot gets no seed. state = applyTerminalAttachStreamEvent(state, { type: "snapshot", snapshot: BASE_SNAPSHOT }); const repeated = readTerminalOutputUpdate(state.output, cursor); expect(repeated).toMatchObject({ type: "reset", data: "hello" }); expect(state.version).toBe(2); + // Viewers replay their measured grid once per generation; the new attachment holds the old size. + expect(state.output.generation).not.toBe(attachedGeneration); cursor = repeated.cursor; state = applyTerminalAttachStreamEvent(state, { diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts index 1fe3b92f2efb7..1678103d8e6e4 100644 --- a/packages/client-runtime/src/state/terminalSession.ts +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -194,12 +194,21 @@ function applyUpstreamTerminalAttachStreamEvent( maxBufferBytes: number, ): TerminalBufferState { switch (event.type) { - case "snapshot": + case "snapshot": { + if (current.version === 0) { + return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current); + } + // A later snapshot is a new server attachment, as after a socket reconnect. It starts a + // new generation because viewers replay their measured grid once per generation. + const reattached = { + ...current, + output: { ...current.output, generation: ++terminalAttachGeneration }, + }; return { - ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current), - lifecycleVersion: - current.version === 0 ? current.lifecycleVersion : current.lifecycleVersion + 1, + ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, reattached), + lifecycleVersion: current.lifecycleVersion + 1, }; + } case "restarted": return { ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current), diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 2b1b1162d6bc9..f587b950635d0 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -13,7 +13,7 @@ import { type OrchestrationV2ShellSnapshot, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -26,6 +26,7 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom, AtomRegistry } from "effect/reactivity"; import * as EnvironmentRegistry from "../connection/registry.ts"; +import { ConnectionTarget } from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import type { RpcSession } from "../rpc/session.ts"; import { createThreadEnvironmentAtoms } from "./threadCommands.ts"; @@ -96,7 +97,13 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio }>(); const reconciled = yield* Queue.unbounded(); const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ - target: { environmentId: ENVIRONMENT_ID }, + // Coder: the gateway target stands in for upstream's bearer target. + target: new ConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Test", + httpBaseUrl: "http://127.0.0.1", + wsBaseUrl: "ws://127.0.0.1", + }), session: yield* SubscriptionRef.make( Option.some({ client: { @@ -117,11 +124,8 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), } as EnvironmentRegistry.EnvironmentRegistry["Service"]), Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: (_algorithm, data) => Effect.succeed(data), - }), + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size)), ), Layer.succeed( ThreadHistoryController.ThreadHistoryController, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index c55c59f0cf1d3..04f2cea415339 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -1,5 +1,5 @@ import type { ThreadId } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; @@ -62,7 +62,7 @@ export type LoadEarlierThreadHistoryInput = { }; export function createThreadEnvironmentAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, snapshotAtom: (environmentId: EnvironmentId) => Atom.Atom, ) { const scheduler = createAtomCommandScheduler(); diff --git a/packages/client-runtime/src/state/threadDetail.test.ts b/packages/client-runtime/src/state/threadDetail.test.ts index 3e66202d0bb4b..cde705bdfb157 100644 --- a/packages/client-runtime/src/state/threadDetail.test.ts +++ b/packages/client-runtime/src/state/threadDetail.test.ts @@ -14,7 +14,12 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { v2Now, v2Projection, v2ThreadId } from "./orchestrationV2TestFixtures.ts"; import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts"; -import { createEnvironmentThreadDetailAtoms, sameThreadStructure } from "./threadDetail.ts"; +import type { ThreadStructureVisibleTurnItem } from "./models.ts"; +import { + createEnvironmentThreadDetailAtoms, + deriveWorkspaceMutationId, + sameThreadStructure, +} from "./threadDetail.ts"; import { mergeOlderHistoryIntoProjection } from "./threadHistoryMerge.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE } from "./threadState.ts"; @@ -349,3 +354,35 @@ it("matches full structure comparisons across streamed, batched, and reordered u [assistant.id, 1], ]); }); + +describe("deriveWorkspaceMutationId", () => { + const item = (id: string, type: string, status: string) => + ({ + item: { id: TurnItemId.make(id), type, status }, + }) as unknown as ThreadStructureVisibleTurnItem; + + it("keys on the latest finished command or file change and the latest checkpoint", () => { + expect( + deriveWorkspaceMutationId( + [ + item("edit", "file_change", "completed"), + item("reply", "assistant_message", "completed"), + item("command", "command_execution", "running"), + ], + "2026-01-01T00:00:00.000Z", + ), + ).toBe(JSON.stringify(["edit", "2026-01-01T00:00:00.000Z"])); + expect( + deriveWorkspaceMutationId( + [item("edit", "file_change", "completed"), item("mcp", "dynamic_tool", "completed")], + null, + ), + ).toBe(JSON.stringify(["mcp", null])); + }); + + it("is null before anything could have touched the workspace", () => { + expect(deriveWorkspaceMutationId([item("reply", "assistant_message", "completed")], null)).toBe( + null, + ); + }); +}); diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index eeeda6a2f348f..7a17e7326b802 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -43,6 +43,38 @@ const EMPTY_THREAD_STRUCTURE_TURN_ITEMS: ReadonlyArray = Object.freeze([]); +/** + * Changes when the agent may have changed the workspace, so file views and host images refresh. + * File tools are explicit; commands, MCP or dynamic tools and subagents are included because + * they can mutate the workspace without reporting the paths they touched. + */ +export function deriveWorkspaceMutationId( + visibleTurnItems: ReadonlyArray, + latestCheckpointCompletedAt: string | null, +) { + let itemId: string | null = null; + for (let index = visibleTurnItems.length - 1; index >= 0; index -= 1) { + const item = visibleTurnItems[index]?.item; + if (!item) continue; + if ( + item.type !== "command_execution" && + item.type !== "file_change" && + item.type !== "dynamic_tool" && + item.type !== "subagent" + ) { + continue; + } + if (item.status === "pending" || item.status === "running" || item.status === "waiting") { + continue; + } + itemId = item.id; + break; + } + return itemId === null && latestCheckpointCompletedAt === null + ? null + : JSON.stringify([itemId, latestCheckpointCompletedAt]); +} + type TurnItem = OrchestrationV2ThreadProjection["turnItems"][number]; type VisibleTurnItem = OrchestrationV2ThreadProjection["visibleTurnItems"][number]; type AppThread = OrchestrationV2ThreadProjection["thread"]; @@ -501,8 +533,19 @@ export function createEnvironmentThreadDetailAtoms( ), ); + const workspaceMutationIdAtomFamily = Atom.family((key: string) => + Atom.make((get) => + deriveWorkspaceMutationId( + get(quietVisibleTurnItemsAtomFamily(key)), + get(checkpointSummariesAtomFamily(key)).at(-1)?.completedAt ?? null, + ), + ).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-workspace-mutation-id:${key}`)), + ); + return { worktreePathAtom: (ref: ScopedThreadRef) => worktreePathAtomFamily(threadKey(ref)), + workspaceMutationIdAtom: (ref: ScopedThreadRef) => + workspaceMutationIdAtomFamily(threadKey(ref)), updatedAtAtom: (ref: ScopedThreadRef) => updatedAtAtomFamily(threadKey(ref)), lastVisitedAtAtom: (ref: ScopedThreadRef) => lastVisitedAtAtomFamily(threadKey(ref)), pendingRequestsAtom: (ref: ScopedThreadRef) => pendingRequestsAtomFamily(threadKey(ref)), diff --git a/packages/client-runtime/src/state/threadHistoryMerge.test.ts b/packages/client-runtime/src/state/threadHistoryMerge.test.ts index 59e1ea3652e52..3f2be832a2b23 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.test.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.test.ts @@ -5,6 +5,7 @@ import * as DateTime from "effect/DateTime"; import { v2Projection, v2ThreadId } from "./orchestrationV2TestFixtures.ts"; import { mergeOlderHistoryIntoProjection } from "./threadHistoryMerge.ts"; import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts"; +import { derivePendingThreadRequests } from "./threadRequests.ts"; const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); @@ -222,6 +223,97 @@ describe("threadHistoryMerge", () => { expect(next?.visibleTurnItems[3]?.item).toEqual(liveItem); }); + // regression: #25806 a pending question card retained outside the window shows when paged in + it.each(["completed", "rolled_back"] as const)( + "keeps a pending question actionable when paging a %s run", + (status) => { + const recent = row(5); + const runId = "question-run" as never; + const nodeId = "question-node" as never; + const card = { + id: "item-question" as never, + type: "user_input_request" as const, + threadId: v2ThreadId, + runId, + nodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running" as const, + title: null, + requestId: "request-question" as never, + responseMode: "message" as const, + questions: [{ id: "scope", header: "Scope", question: "Which scope?", options: [] }], + startedAt: NOW, + completedAt: null, + updatedAt: NOW, + }; + const coldOpen = { + ...v2Projection, + runs: [ + { + id: runId, + threadId: v2ThreadId, + ordinal: 1, + providerInstanceId: v2Projection.thread.providerInstanceId, + modelSelection: v2Projection.thread.modelSelection, + providerThreadId: null, + userMessageId: "question-message" as never, + rootNodeId: nodeId, + activeAttemptId: null, + status, + requestedAt: NOW, + startedAt: NOW, + completedAt: NOW, + checkpointId: null, + contextHandoffId: null, + }, + ], + runtimeRequests: [ + { + id: card.requestId, + nodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input" as const, + status: "pending" as const, + responseCapability: { type: "message" as const }, + createdAt: NOW, + resolvedAt: null, + }, + ], + turnItems: [card, recent.item], + visibleTurnItems: [{ ...recent, position: 0 }], + }; + + const merged = mergeOlderHistoryIntoProjection(coldOpen, [ + { ...row(0), position: 0 }, + { + position: 1, + visibility: "local", + sourceThreadId: v2ThreadId, + sourceItemId: card.id, + item: card, + }, + ]); + + expect(merged.visibleTurnItems.map((entry) => String(entry.sourceItemId))).toEqual( + status === "rolled_back" ? ["item-0", "item-5"] : ["item-0", "item-question", "item-5"], + ); + expect(merged.turnItems).toContain(card); + const pending = derivePendingThreadRequests(merged).userInputs; + expect(pending).toEqual(derivePendingThreadRequests(coldOpen).userInputs); + expect(pending).toHaveLength(1); + expect(pending[0]).toMatchObject({ + requestId: card.requestId, + dismissible: true, + questions: card.questions.map((question) => ({ ...question, multiSelect: false })), + }); + }, + ); + it("keeps a history-page interrupt result visible when its request was retained at cold open", () => { const runId = "run-interrupt-cold" as never; const nodeId = "node-interrupt-cold" as never; diff --git a/packages/client-runtime/src/state/threadHistoryMerge.ts b/packages/client-runtime/src/state/threadHistoryMerge.ts index 6fd678b66b129..62eba89d587f2 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.ts @@ -89,9 +89,13 @@ export function mergeOlderHistoryIntoProjection( (row.visibility === "local" || row.sourceThreadId === projection.thread.id) ) { const currentItem = turnItemById.get(String(row.sourceItemId)); + // Hidden live items stay hidden. Snapshots also retain interrupt requests and + // pending request cards outside the visible window; their history page shows them. if (currentItem !== undefined) { if ( - currentItem.type !== "run_interrupt_request" || + (currentItem.type !== "run_interrupt_request" && + currentItem.type !== "approval_request" && + currentItem.type !== "user_input_request") || !isOrchestrationV2TurnItemVisible({ item: currentItem, runs: projection.runs, @@ -112,10 +116,7 @@ export function mergeOlderHistoryIntoProjection( turnItemById.set(String(row.sourceItemId), row.item); } if (page.length === 0 && work === undefined) return projection; - let visible = [ - ...page, - ...projection.visibleTurnItems.filter((row) => !pageKeys.has(projectedItemKey(row))), - ]; + let visible: OrchestrationV2ProjectedTurnItem[]; if (work && anchor && deferred) { let end = start + 1; while (end < projection.visibleTurnItems.length) { @@ -147,6 +148,11 @@ export function mergeOlderHistoryIntoProjection( ...projection.visibleTurnItems.slice(end), ]; if (anchor.visibility === "local") turnItemById.set(String(updated.item.id), updated.item); + } else { + visible = [ + ...page, + ...projection.visibleTurnItems.filter((row) => !pageKeys.has(projectedItemKey(row))), + ]; } if ( visible.length === projection.visibleTurnItems.length && diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index ba5e119438290..2669416db42fe 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -123,7 +123,7 @@ export function createInboxReturnTracker() { const keyOf = (thread: ThreadKeyInput) => `${thread.environmentId}:${thread.id}`; const states = new Map< string, - { working: boolean; deadline: number | null; placement: string } + { working: boolean; deadline: number | null; placement: string | null } >(); const returns = new Map(); const listeners = new Set<() => void>(); @@ -184,17 +184,21 @@ export function createInboxReturnTracker() { for (const thread of threads) { const key = keyOf(thread); present.add(key); - const placement = JSON.stringify([ - thread.archivedAt, - thread.deletedAt, - thread.pinnedAt, - thread.settledOverride, - thread.settledAt, - thread.snoozedAt, - thread.snoozedUntil, - thread.activeOrderKey, - ]); const previous = states.get(key); + const actual = isThreadWorking(thread); + const placement = + actual || previous?.working + ? JSON.stringify([ + thread.archivedAt, + thread.deletedAt, + thread.pinnedAt, + thread.settledOverride, + thread.settledAt, + thread.snoozedAt, + thread.snoozedUntil, + thread.activeOrderKey, + ]) + : null; const actionable = thread.hasPendingApprovals || thread.hasPendingUserInput || @@ -210,7 +214,6 @@ export function createInboxReturnTracker() { thread.pinnedAt != null || thread.settledOverride === "settled" || effectiveSnoozed(thread, { now: observedAt }); - const actual = isThreadWorking(thread); // Only shelf placement is held: execution state and attention requests stay authoritative. const deadline = !actual && previous?.working && !actionable && !moved && !excluded diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index d9aaf9fdca71a..c2e1024e232ae 100644 --- a/packages/client-runtime/src/state/threadRequests.test.ts +++ b/packages/client-runtime/src/state/threadRequests.test.ts @@ -2,6 +2,8 @@ import { presentThreadShell } from "./models.ts"; import { threadKey } from "./entities.ts"; import { EnvironmentId, + EventId, + MessageId, ThreadId, type ScopedThreadRef, NodeId, @@ -13,6 +15,7 @@ import { import { describe, expect, it, vi } from "vite-plus/test"; import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; import * as Cause from "effect/Cause"; +import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts"; import { v2Now, v2Projection, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { createQuestionHistoryProjector, @@ -360,6 +363,210 @@ it("restores old text answers without mutating history or replacing unchanged ro expect(project({ ...answered, visibleTurnItems: [...rows] })[0]).toBe(result[0]); }); +type QuestionItem = Extract< + OrchestrationV2ThreadProjection["turnItems"][number], + { readonly type: "user_input_request" } +>; + +describe("question history through exact row replacements", () => { + const baseItem = { + threadId: v2Projection.thread.id, + runId: null, + nodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + status: "running" as const, + title: null, + startedAt: v2Now, + completedAt: null, + updatedAt: v2Now, + }; + const assistantItem = (ordinal: number, text = "Hel") => ({ + ...baseItem, + id: TurnItemId.make(`assistant-${ordinal}`), + ordinal, + type: "assistant_message" as const, + messageId: MessageId.make(`message-${ordinal}`), + text, + streaming: true, + }); + const localRow = ( + item: OrchestrationV2ThreadProjection["turnItems"][number], + position: number, + ) => ({ + item, + position, + sourceItemId: item.id, + sourceThreadId: item.threadId, + visibility: "local" as const, + }); + const updated = ( + current: OrchestrationV2ThreadProjection, + item: OrchestrationV2ThreadProjection["turnItems"][number], + ) => + applyOrchestrationV2ProjectionEvent(current, { + id: EventId.make(`event-${item.id}`), + type: "turn-item.updated", + threadId: current.thread.id, + occurredAt: v2Now, + payload: item, + })!; + + it("reads only the replaced row and leaves the request index alone", () => { + const size = 1_000; + const counts = { items: 0, requestIds: 0 }; + const items = Array.from({ length: size }, (_, index) => assistantItem(index)); + const countedRows = items.map((item, index) => { + const row = localRow(item, index); + return Object.defineProperty(row, "item", { + enumerable: true, + get: () => { + counts.items += 1; + return item; + }, + }); + }); + const countedRequests = Array.from({ length: 400 }, (_, index) => { + const request = { + ...projection.runtimeRequests[0]!, + id: RuntimeRequestId.make(`request-${index}`), + }; + return Object.defineProperty(request, "id", { + enumerable: true, + get: () => { + counts.requestIds += 1; + return RuntimeRequestId.make(`request-${index}`); + }, + }); + }); + const initial: OrchestrationV2ThreadProjection = { + ...v2Projection, + turnItems: items, + visibleTurnItems: countedRows, + runtimeRequests: countedRequests, + }; + const project = createQuestionHistoryProjector(); + const scan = (current: OrchestrationV2ThreadProjection) => { + counts.items = 0; + counts.requestIds = 0; + const result = project(current); + return { result, ...counts }; + }; + expect(scan(initial)).toMatchObject({ items: size, requestIds: 400 }); + + const adjacent = updated(initial, assistantItem(size - 1, "Hello")); + const hinted = scan(adjacent); + expect(hinted.result).toBe(adjacent.visibleTurnItems); + expect(hinted).toMatchObject({ items: 0, requestIds: 0 }); + + // A skipped delta has no hint for this exact pair, so it scans again. + const skipped = updated( + updated(adjacent, assistantItem(size - 1, "Hello t")), + assistantItem(size - 1, "Hello there"), + ); + const fallback = scan(skipped); + expect(fallback.result).toBe(skipped.visibleTurnItems); + expect(fallback).toMatchObject({ items: size - 1, requestIds: 0 }); + }); + + it("matches a full projection through streamed rows, request changes and answers", () => { + const question = projection.turnItems[0] as QuestionItem; + const resolved = (answers: Record) => ({ + ...projection.runtimeRequests[0]!, + status: "resolved" as const, + answers, + }); + const explicitRequestId = RuntimeRequestId.make("explicit-question"); + const explicitAnswer = { + requestId: explicitRequestId, + answers: { next: "Explicit" }, + attachmentsByQuestionId: {}, + questionTextById: { next: "What should happen next?" }, + }; + const unanswered: QuestionItem = { + ...question, + id: TurnItemId.make("explicit-question-item"), + ordinal: 1, + requestId: explicitRequestId, + }; + const explicit: QuestionItem = { ...unanswered, questionAnswer: explicitAnswer }; + const explicitRequest = { + ...resolved({ next: "From request" }), + id: explicitRequestId, + }; + const stream = assistantItem(2); + const inherited = { + ...localRow({ ...question, id: TurnItemId.make("inherited-question-item") }, 3), + visibility: "inherited" as const, + sourceThreadId: ThreadId.make("parent-thread"), + }; + const turnItems = [question, explicit, stream]; + let current: OrchestrationV2ThreadProjection = { + ...v2Projection, + turnItems, + visibleTurnItems: [...turnItems.map(localRow), inherited], + runtimeRequests: [projection.runtimeRequests[0]!, explicitRequest], + }; + const project = createQuestionHistoryProjector(); + const step = (next: OrchestrationV2ThreadProjection) => { + current = next; + const result = project(next); + expect(result).toEqual(createQuestionHistoryProjector()(next)); + return result; + }; + const answerOf = (rows: ReadonlyArray<{ readonly item: unknown }>, index: number) => + (rows[index]!.item as { readonly questionAnswer?: { readonly answers: unknown } }) + .questionAnswer?.answers; + + expect(step(current)).toBe(current.visibleTurnItems); + expect(step(updated(current, assistantItem(2, "Hello")))).toBe(current.visibleTurnItems); + + // The last duplicate request wins, and every row asking it gets the answer. + const answered = step({ + ...current, + runtimeRequests: [ + projection.runtimeRequests[0]!, + resolved({ next: "Continue" }), + explicitRequest, + ], + }); + expect(answerOf(answered, 0)).toEqual({ next: "Continue" }); + expect(answerOf(answered, 1)).toEqual({ next: "Explicit" }); + expect(answerOf(answered, 3)).toEqual({ next: "Continue" }); + + const whileOverlaid = step(updated(current, assistantItem(2, "Hello there"))); + expect(whileOverlaid[0]).toBe(answered[0]); + expect(whileOverlaid[3]).toBe(answered[3]); + + const changedAnswer = step({ + ...current, + runtimeRequests: [resolved({ next: "Stop" }), explicitRequest], + }); + expect(answerOf(changedAnswer, 0)).toEqual({ next: "Stop" }); + + const reopened = step({ + ...current, + runtimeRequests: [projection.runtimeRequests[0]!, explicitRequest], + }); + expect(reopened).toBe(current.visibleTurnItems); + + // Dropping an explicit answer falls back to its resolved request. + const restored = step(updated(current, unanswered)); + expect(answerOf(restored, 1)).toEqual({ next: "From request" }); + + const retitled: QuestionItem = { + ...unanswered, + questions: [{ ...question.questions[0]!, question: "Now what?" }], + }; + const retitledRows = step(updated(current, retitled)); + expect(retitledRows[1]!.item).toMatchObject({ + questionAnswer: { questionTextById: { next: "Now what?" } }, + }); + }); +}); + it("loads only child approvals and retains their destination across environments", () => { const local = EnvironmentId.make("local"); const remote = EnvironmentId.make("remote"); diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index d930be7d77a96..2d3926f77c430 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -13,6 +13,7 @@ import type { } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { parseThreadKey, threadKey } from "./entities.ts"; +import { getProjectionArrayReplacementIndex } from "./orchestrationV2Projection.ts"; import type { EnvironmentThreadShell } from "./models.ts"; import * as DateTime from "effect/DateTime"; import type { AtomCommandResult } from "./runtime.ts"; @@ -292,54 +293,61 @@ export function createQuestionHistoryProjector< let previousRows: Rows | undefined; let previousRequests: Requests | undefined; let previousResult: Rows | undefined; + let byId = new Map(); const cache = new WeakMap(); - return (projection: { - readonly visibleTurnItems: Rows; - readonly runtimeRequests: Requests; - }): Rows => { - const { visibleTurnItems: rows, runtimeRequests: requests } = projection; - if (rows === previousRows && requests === previousRequests && previousResult) - return previousResult; - const byId = new Map(requests.map((request) => [request.id, request])); + const projectRow = (row: Row): Row => { + const item = row.item; + if (item.type !== "user_input_request" || item.questionAnswer) return row; + const request = byId.get(item.requestId); + if (request?.status !== "resolved" || request.answers === undefined) return row; + const cached = cache.get(row); + const next: Row = + cached?.request === request + ? cached.row + : { + ...row, + item: { + ...item, + questionAnswer: { + requestId: request.id, + answers: request.answers, + attachmentsByQuestionId: {}, + questionTextById: Object.fromEntries( + item.questions.map((question) => [question.id, question.question]), + ), + }, + }, + }; + cache.set(row, { request, row: next }); + return next; + }; + const projectRows = (rows: Rows): Rows => { let result: Row[] | undefined; for (const [index, row] of rows.entries()) { - const item = row.item; - const request = - item.type === "user_input_request" && !item.questionAnswer - ? byId.get(item.requestId) - : undefined; - let next = row; - if ( - item.type === "user_input_request" && - request?.status === "resolved" && - request.answers !== undefined - ) { - const cached = cache.get(row); - next = - cached?.request === request - ? cached.row - : { - ...row, - item: { - ...item, - questionAnswer: { - requestId: request.id, - answers: request.answers, - attachmentsByQuestionId: {}, - questionTextById: Object.fromEntries( - item.questions.map((question) => [question.id, question.question]), - ), - }, - }, - }; - cache.set(row, { request, row: next }); - } + const next = projectRow(row); if (next !== row) result ??= rows.slice(0, index); result?.push(next); } + return result ?? rows; + }; + return (projection: { + readonly visibleTurnItems: Rows; + readonly runtimeRequests: Requests; + }): Rows => { + const { visibleTurnItems: rows, runtimeRequests: requests } = projection; + const sameRequests = requests === previousRequests; + if (rows === previousRows && sameRequests && previousResult) return previousResult; + if (!sameRequests) byId = new Map(requests.map((request) => [request.id, request])); + // Nothing was overlaid and requests are unchanged, so only an exactly replaced row can differ. + const replacedIndex = + sameRequests && previousRows !== undefined && previousResult === previousRows + ? getProjectionArrayReplacementIndex(previousRows, rows) + : undefined; + const unchanged = + replacedIndex !== undefined && projectRow(rows[replacedIndex]!) === rows[replacedIndex]; previousRows = rows; previousRequests = requests; - previousResult = result ?? rows; + previousResult = unchanged ? rows : projectRows(rows); return previousResult; }; } diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 6ad788e0a6623..fa1c33fb8341f 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -96,14 +96,14 @@ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean ); } -// V2 runtime.updatedAt includes metadata writes; prefer the failed run's completion. +// V2 runtime.updatedAt includes metadata writes, not just failures. // Legacy sessions only expose the runtime timestamp. function failureEdgeAt(shell: ThreadSnoozeShell): string | null | undefined { const runtime = shell.runtime ?? shell.session ?? null; if (runtime?.status !== "error" && runtime?.status !== "failed") return undefined; const latestRun = shell.latestRun ?? shell.latestTurn ?? null; - if (latestRun?.status === "failed" && latestRun.completedAt != null) { - return latestRun.completedAt; + if (latestRun?.status === "failed") { + return latestRun.completedAt ?? null; } return runtime.updatedAt ?? null; } diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.test.ts b/packages/client-runtime/src/state/threadSnapshotHttp.test.ts index 008c53dc0fbc6..71e96020c1d4c 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.test.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.test.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as SubscriptionRef from "effect/SubscriptionRef"; import type * as EnvironmentSupervisor from "../connection/supervisor.ts"; import type { PreparedConnection } from "../connection/model.ts"; @@ -14,6 +15,8 @@ import type { RpcSession } from "../rpc/session.ts"; import { fetchEnvironmentThreadHistoryPage } from "./threadHistoryHttp.ts"; import { makeThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; +class MissingSnapshot extends Schema.TaggedError()("MissingSnapshot", {}) {} + type Supervisor = EnvironmentSupervisor.EnvironmentSupervisor["Service"]; describe("Coder thread snapshot loader", () => { @@ -80,7 +83,7 @@ describe("Coder thread snapshot loader", () => { Option.some({ client: { [WS_METHODS.orchestrationGetThreadBoundedSnapshot]: () => - Effect.fail(new Error("missing snapshot")), + Effect.fail(new MissingSnapshot()), }, } as unknown as RpcSession), ); diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index 306ea4316d61e..1c8abb3f5710a 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -45,29 +45,28 @@ export function makeThreadSnapshotLoader( supervisor: EnvironmentSupervisor.EnvironmentSupervisor["Service"], ): ThreadSnapshotLoader["Service"] { return ThreadSnapshotLoader.of({ - load: (_prepared, threadId) => - Effect.gen(function* () { - const session = yield* SubscriptionRef.get(supervisor.session); - if (Option.isNone(session)) return { _tag: "unavailable" } as const; - return yield* session.value.client[WS_METHODS.orchestrationGetThreadBoundedSnapshot]({ - threadId, - deferWork: true, - }).pipe( - Effect.map((bounded): ThreadSnapshotLoadResult => ({ - _tag: "present", - snapshot: { - snapshotSequence: bounded.snapshotSequence, - projection: bounded.projection, - latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, - }, - history: { - historyCursor: bounded.historyCursor, - hasMoreHistory: bounded.hasMoreHistory, - latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, - }, - })), - Effect.catchCause(() => Effect.succeed({ _tag: "unavailable" } as const)), - ); - }), + load: Effect.fn("ThreadSnapshotLoader.load")(function* (_prepared, threadId) { + const session = yield* SubscriptionRef.get(supervisor.session); + if (Option.isNone(session)) return { _tag: "unavailable" } as const; + return yield* session.value.client[WS_METHODS.orchestrationGetThreadBoundedSnapshot]({ + threadId, + deferWork: true, + }).pipe( + Effect.map((bounded): ThreadSnapshotLoadResult => ({ + _tag: "present", + snapshot: { + snapshotSequence: bounded.snapshotSequence, + projection: bounded.projection, + latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, + }, + history: { + historyCursor: bounded.historyCursor, + hasMoreHistory: bounded.hasMoreHistory, + latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, + }, + })), + Effect.catchCause(() => Effect.succeed({ _tag: "unavailable" } as const)), + ); + }), }); } diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index adf9227bf010c..f8f01ec6a1d9a 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -96,6 +96,18 @@ describe("effectiveSnoozed", () => { pending: undefined, wokeAt: null, }, + { + reason: "failure without a completion timestamp", + completedAt: null, + pending: undefined, + wokeAt: null, + }, + { + reason: "approval without a failure completion timestamp", + completedAt: null, + pending: "approval", + wokeAt: "2026-04-10T11:00:00.000Z", + }, { reason: "fresh failure", completedAt: "2026-04-10T10:00:00.000Z", diff --git a/packages/client-runtime/src/state/threadVisits.test.ts b/packages/client-runtime/src/state/threadVisits.test.ts index adcac7139e82a..c67ca4fa807c9 100644 --- a/packages/client-runtime/src/state/threadVisits.test.ts +++ b/packages/client-runtime/src/state/threadVisits.test.ts @@ -111,6 +111,25 @@ it("keeps a mark-unread on the open thread until new activity lands", () => { expect(t.visits.slice(1)).toEqual([{ threadId: t.ref.threadId, visitedAt: at(30) }]); }); +it("keeps a mark-unread on a read thread reopened within the open dedupe window", () => { + using t = setup("thread-unread-reopened", { + updatedAt: at(10), + lastVisitedAt: at(10), + latestRun: { completedAt: at(9) }, + }); + claimThreadOpen(environmentId, t.ref.threadId, Date.UTC(2026, 0, 1)); + t.mount(); + t.update({ lastVisitedAt: at(8) }); + vi.advanceTimersByTime(VISIT_DISPATCH_THROTTLE_MS); + expect(t.visits).toEqual([]); + + // An authoritative snapshot can briefly replace the row with an older one. + t.update({ updatedAt: at(5), lastVisitedAt: at(5), latestRun: { completedAt: at(4) } }); + t.update({ updatedAt: at(10), lastVisitedAt: at(8), latestRun: { completedAt: at(9) } }); + vi.advanceTimersByTime(VISIT_DISPATCH_THROTTLE_MS); + expect(t.visits).toEqual([]); +}); + it("keeps mark unread sticky after an expired wake is retired", () => { using t = setup("thread-unread-retired-wake", { updatedAt: at(10), diff --git a/packages/client-runtime/src/state/threadVisits.ts b/packages/client-runtime/src/state/threadVisits.ts index c08f47ed01436..e63c2ad9c0279 100644 --- a/packages/client-runtime/src/state/threadVisits.ts +++ b/packages/client-runtime/src/state/threadVisits.ts @@ -114,7 +114,12 @@ export function createThreadVisitTracker(options: ThreadVisitTrackerOptions) { return; } const lastVisitedAtMs = shell.lastVisitedAt ? Date.parse(shell.lastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= updatedAtMs) return; + if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= updatedAtMs) { + // Already seen, so a later mark-unread stays sticky even when no open was sent. + if (lastDispatchedUpdatedAt === null || Date.parse(lastDispatchedUpdatedAt) < updatedAtMs) + lastDispatchedUpdatedAt = visitedAt; + return; + } if (lastDispatchedUpdatedAt !== null && Date.parse(lastDispatchedUpdatedAt) >= updatedAtMs) return; diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index f3a2aa98939c2..253a1980dd319 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -106,7 +106,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? threadResumeCompletionMarker: true, threadSnapshotPagination: true, } as never), - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index aabf2bd119c39..de4381f2d2926 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -59,6 +59,11 @@ import { } from "./threads.ts"; import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; +// Coder: helper RPC fakes fail with a tagged error where upstream answers HTTP 503. +class FakeRpcFailure extends Schema.TaggedError()("FakeRpcFailure", { + message: Schema.String, +}) {} + const TARGET = new ConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), label: "Test environment", @@ -85,7 +90,7 @@ function testSession(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.succeed({} as never), - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), @@ -318,6 +323,32 @@ const PENDING_QUESTION = { resolvedAt: null, }; +const QUESTION_CARD: OrchestrationV2TurnItem = { + id: TurnItemId.make("question-card"), + threadId: THREAD_ID, + runId: null, + nodeId: PENDING_QUESTION.nodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running", + title: null, + startedAt: v2Now, + completedAt: null, + updatedAt: v2Now, + type: "user_input_request", + requestId: PENDING_QUESTION.id, + questions: [{ id: "scope", header: "Scope", question: "Which scope?", options: [] }], +}; + +const PROJECTION_WITH_QUESTION = { + ...BASE_PROJECTION, + runtimeRequests: [PENDING_QUESTION], + turnItems: [QUESTION_CARD], +}; + const questionResolved = (sequence: number): OrchestrationV2ThreadStreamItem => ({ kind: "event", sequence, @@ -399,6 +430,62 @@ describe("EnvironmentThreads", () => { ); } + it.effect.each([ + ["disk", false], + ["retained", false], + ["disk", true], + ["retained", true], + ] as const)( + "recovers a %s cache only when its pending question card is missing: %s", + ([cacheKind, hasCard]) => + Effect.gen(function* () { + const projection = { + ...PROJECTION_WITH_QUESTION, + turnItems: hasCard ? [QUESTION_CARD] : [], + }; + const schema = Schema.fromJsonString(StoredOrchestrationThreadSnapshot); + const encoded = yield* Schema.encodeEffect(schema)({ + schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, + environmentId: TARGET.environmentId, + threadId: THREAD_ID, + snapshot: { snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, projection }, + }); + const stored = yield* Schema.decodeEffect(schema)(encoded); + const h = yield* makeHarness({ + loadCached: Effect.succeedSome(stored.snapshot), + ...(cacheKind === "retained" + ? { + resumeCache: { + owner: undefined, + snapshot: { + state: { + ...EMPTY_ENVIRONMENT_THREAD_STATE, + data: Option.some(projection), + status: "live", + }, + sequence: CACHED_SNAPSHOT_SEQUENCE, + persisted: true, + }, + }, + } + : {}), + httpSnapshot: { + _tag: "present", + snapshot: { + snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, + projection: PROJECTION_WITH_QUESTION, + }, + }, + completionMarker: true, + }); + yield* Queue.offer(h.inputs, synchronized()); + const state = yield* awaitThreadState(h.observed, (value) => value.status === "live"); + expect(yield* Ref.get(h.loaderCalls)).toBe(hasCard ? 0 : 1); + expect(Option.getOrThrow(state.data)).toEqual(PROJECTION_WITH_QUESTION); + expect(yield* Ref.get(h.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE); + }), + ); + it.effect("discards an oversized version-4 cache and requests a fresh bounded snapshot", () => Effect.gen(function* () { const item: OrchestrationV2TurnItem = { @@ -598,7 +685,7 @@ describe("EnvironmentThreads", () => { Scope.close(scope, Exit.void), ); const old = yield* makeHarness({ - cached: { ...BASE_PROJECTION, runtimeRequests: [PENDING_QUESTION] }, + cached: PROJECTION_WITH_QUESTION, resumeCache, }).pipe(Effect.provideService(Scope.Scope, oldScope)); yield* awaitThreadState(old.observed, (value) => value.status === "live"); @@ -835,7 +922,7 @@ describe("EnvironmentThreads", () => { if (cursor === "before-user") return historyPage([], cursor); if (cursor === "925" && !failed) { failed = true; - return Effect.fail(new Error("Unavailable")); + return Effect.fail(new FakeRpcFailure({ message: "Unavailable" })); } const end = Number(cursor); expect(Number.isFinite(end)).toBe(true); @@ -882,7 +969,7 @@ describe("EnvironmentThreads", () => { requests.push(cursor); if (cursor === "second" && !failed) { failed = true; - return Effect.fail(new Error("Unavailable")); + return Effect.fail(new FakeRpcFailure({ message: "Unavailable" })); } return historyPage( [localRow(command(`work-${cursor}`, "completed", 0, cursor === "first" ? 3 : 2), 0)], @@ -1007,7 +1094,7 @@ describe("EnvironmentThreads", () => { historyPage: ({ view }) => { views.push(view ?? null); return view === "work" - ? Effect.fail(new Error("Unavailable")) + ? Effect.fail(new FakeRpcFailure({ message: "Unavailable" })) : historyPage([localRow(message, 0)], null); }, }); @@ -1579,7 +1666,8 @@ describe("EnvironmentThreads", () => { if (!conversationOnly && activityRequests++ === 0) { yield* Deferred.succeed(activityStarted, undefined); yield* Deferred.await(releaseActivity); - if (mode === "retry") return yield* Effect.fail(new Error("Activity unavailable")); + if (mode === "retry") + return yield* new FakeRpcFailure({ message: "Activity unavailable" }); } return { snapshotSequence: 14, @@ -2170,7 +2258,7 @@ describe("EnvironmentThreads", () => { (unsubscribe) => Effect.sync(unsubscribe), ); const harness = yield* makeHarness({ - cached: { ...BASE_PROJECTION, runtimeRequests: [PENDING_QUESTION] }, + cached: PROJECTION_WITH_QUESTION, completionMarker: true, }); yield* awaitThreadState( diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 60aba48bc02f0..6350ed83b8784 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -188,6 +188,20 @@ function cachedThreadState(value: EnvironmentThreadState): EnvironmentThreadStat }; } +function hasMissingPendingQuestionCard(projection: OrchestrationV2ThreadProjection): boolean { + const pending = new Set( + projection.runtimeRequests + .filter((request) => request.kind === "user_input" && request.status === "pending") + .map((request) => request.id), + ); + if (pending.size === 0) return false; + for (const item of projection.turnItems) { + if (item.type === "user_input_request") pending.delete(item.requestId); + if (pending.size === 0) return false; + } + return true; +} + export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( threadId: ThreadIdType, resumeCache?: ThreadResumeCache, @@ -205,7 +219,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); const environmentId = supervisor.target.environmentId; - const retained = resumeCache?.snapshot; + const candidate = resumeCache?.snapshot; + const retained = + candidate && + Option.isSome(candidate.state.data) && + hasMissingPendingQuestionCard(candidate.state.data.value) + ? undefined + : candidate; const owner = {}; // Coder: bounded idle retention must release active snapshots and charge them after stream cleanup. if (resumeCache) { @@ -219,7 +239,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }), ); } - const cached = + const loaded = retained === undefined ? yield* cache.loadThread(environmentId, threadId).pipe( Effect.catch((error) => @@ -234,6 +254,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ), ) : Option.none(); + // Old bounded caches can omit a pending question's card; replay alone cannot restore it. + const cached = Option.filter( + loaded, + (snapshot) => !hasMissingPendingQuestionCard(snapshot.projection), + ); const cachedThread = Option.map(cached, (snapshot) => snapshot.projection); // Seed the resume cursor from the cached snapshot so a warm cache can catch up // via `afterSequence` instead of re-downloading the full thread body. diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index fa0227eaef24c..9e65be8f4a337 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -4,6 +4,7 @@ import { AuthSourceControlWriteScope, WS_METHODS, VcsProcessExitError, + type VcsListRefsInput, type VcsListRefsResult, type VcsStatusResult, } from "@t3tools/contracts"; @@ -33,6 +34,7 @@ import * as Persistence from "../platform/persistence.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; +import { createGitEnvironmentAtoms } from "./git.ts"; import { commitVcsRefsRefresh, @@ -93,7 +95,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), @@ -154,6 +156,98 @@ describe("cached VCS refs", () => { registry.dispose(); }); + it.effect("bypasses the server ref snapshot when a mounted list is invalidated", () => + Effect.scoped( + Effect.gen(function* () { + const requests = yield* Queue.unbounded(); + const client = { + [WS_METHODS.vcsListRefs]: (input: VcsListRefsInput) => + Effect.sync(() => { + Queue.offerUnsafe(requests, input); + return LIVE_REFS; + }), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const followStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["followStream"] = ( + _environmentId, + stream, + ) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + followStream, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Layer.succeed(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())), + ), + ); + const atoms = createVcsEnvironmentAtoms(runtime); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + yield* Effect.acquireRelease( + Effect.sync(() => + registry.mount( + atoms.listRefs({ + environmentId: TARGET.environmentId, + input: { cwd: "/repo", limit: 100 }, + }), + ), + ), + (unmount) => Effect.sync(unmount), + ); + + expect(yield* Queue.take(requests)).toEqual({ cwd: "/repo", limit: 100 }); + + invalidateVcsRefs(registry, TARGET); + + expect(yield* Queue.take(requests)).toEqual({ cwd: "/repo", limit: 100, refresh: true }); + + yield* Effect.acquireRelease( + Effect.sync(() => + registry.mount( + atoms.listRefs({ + environmentId: TARGET.environmentId, + input: { cwd: "/other-repo", limit: 100 }, + }), + ), + ), + (unmount) => Effect.sync(unmount), + ); + expect(yield* Queue.take(requests)).toEqual({ + cwd: "/other-repo", + limit: 100, + refresh: true, + }); + + yield* Effect.acquireRelease( + Effect.sync(() => + registry.mount( + atoms.listRefs({ + environmentId: TARGET.environmentId, + input: { cwd: "/other-repo", query: "release", limit: 100 }, + }), + ), + ), + (unmount) => Effect.sync(unmount), + ); + expect(yield* Queue.take(requests)).toEqual({ + cwd: "/other-repo", + query: "release", + limit: 100, + }); + }), + ), + ); + it.effect("does not repersist a refresh superseded by ref invalidation", () => Effect.scoped( Effect.gen(function* () { @@ -223,6 +317,8 @@ describe("cached VCS refs", () => { const client = { [WS_METHODS.vcsPull]: () => Effect.fail(expectedError), [WS_METHODS.vcsRefreshStatus]: () => Effect.void, + [WS_METHODS.gitPreparePullRequestThread]: () => + Effect.succeed({ branch: "feature/pr", worktreePath: null }), } as unknown as WsRpcProtocolClient; const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, @@ -278,6 +374,17 @@ describe("cached VCS refs", () => { expect(AsyncResult.isSuccess(refreshResult)).toBe(true); expect(yield* Ref.get(clears)).toBe(2); expect(registry.get(vcsRefsCacheStateAtom(TARGET)).revision).toBe(2); + + const prepareResult = yield* Effect.promise(() => + createGitEnvironmentAtoms(runtime).preparePullRequestThread.run(registry, { + environmentId: TARGET.environmentId, + input: { cwd: "/repo", reference: "42", mode: "local" }, + }), + ); + + expect(AsyncResult.isSuccess(prepareResult)).toBe(true); + expect(yield* Ref.get(clears)).toBe(3); + expect(registry.get(vcsRefsCacheStateAtom(TARGET)).revision).toBe(3); }), ), ); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 0369d89ae90d4..82e3ffd0f3e66 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -33,6 +33,9 @@ import { withVcsRefsPersistenceLock, } from "./vcsRefInvalidation.ts"; +// Share freshness across search and pagination keys, but keep it local to this registry. +const serverRefsRevisionAtom = Atom.family((_key: string) => Atom.make(0).pipe(Atom.keepAlive)); + const OFFLINE_BRANCH_LIST_LIMIT = 100; const VCS_REFS_IDLE_TTL_MS = 30_000; const VCS_STATUS_IDLE_TTL_MS = 10_000; @@ -143,9 +146,17 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange ) : Option.none(); const refresh = Effect.fn("CachedVcsRefsState.refresh")(function* () { - const refs = yield* request(WS_METHODS.vcsListRefs, input).pipe( - Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + const revisionAtom = serverRefsRevisionAtom( + `${environmentId.length}:${environmentId}${input.cwd}`, ); + const bypassServerSnapshot = + expectedRevision !== undefined && + registry !== undefined && + expectedRevision > registry.get(revisionAtom); + const refs = yield* request( + WS_METHODS.vcsListRefs, + bypassServerSnapshot ? { ...input, refresh: true } : input, + ).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); const persist = cache.saveVcsRefs(environmentId, input.cwd, refs).pipe( Effect.catch((error) => Effect.logWarning("Could not persist cached Git refs.").pipe( @@ -168,6 +179,7 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange expectedRevision, persist: useCache, }); + if (committed && bypassServerSnapshot) registry.set(revisionAtom, expectedRevision); return committed ? Option.some(refs) : Option.none(); }); diff --git a/packages/client-runtime/src/threadReference.test.ts b/packages/client-runtime/src/threadReference.test.ts index 9f0fe6306d5d1..4c045c44cbd7d 100644 --- a/packages/client-runtime/src/threadReference.test.ts +++ b/packages/client-runtime/src/threadReference.test.ts @@ -3,7 +3,7 @@ import { decodeComposerContextFragment, encodeComposerContextFragment, } from "@t3tools/shared/composerContextClipboard"; -import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { threadLinkContextReferences } from "@t3tools/shared/composerContextReferences"; import { describe, expect, it } from "vite-plus/test"; import { resolveThreadReferenceCopyTarget } from "./threadReference.ts"; @@ -98,13 +98,18 @@ describe("resolveThreadReferenceCopyTarget", () => { }), ], }); - // A paste keeps only the records its plain text references. - expect(collectComposerContextReferences(target!.value)).toEqual([ - expect.objectContaining({ - kind: "thread", - contextId: pasted!.records[0]!.contextId, - label: "Fix the login flow", - }), - ]); + // The plain text opens the thread anywhere and pastes back as the copied record's chip. + expect(target!.value).toBe("https://app.t3.codes/threads/env-1/thread-1"); + expect(threadLinkContextReferences(target!.value, pasted!.records, () => undefined)).toEqual({ + text: `[Fix the login flow](t3-context://v1/thread/${pasted!.records[0]!.contextId})`, + records: pasted!.records, + }); + }); + + it("links through the build's hosted app", () => { + expect( + resolveThreadReferenceCopyTarget({ ...thread, hostedAppUrl: "https://preview.t3.codes" }) + ?.value, + ).toBe("https://preview.t3.codes/threads/env-1/thread-1"); }); }); diff --git a/packages/client-runtime/src/threadReference.ts b/packages/client-runtime/src/threadReference.ts index 1f5412c8fdb17..2974fbde02247 100644 --- a/packages/client-runtime/src/threadReference.ts +++ b/packages/client-runtime/src/threadReference.ts @@ -3,16 +3,14 @@ import type { ScopedThreadRef, ThreadPullRequestLink, } from "@t3tools/contracts"; -import { - formatComposerContextReference, - threadContextRecord, -} from "@t3tools/shared/composerContextReferences"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; +import { hostedThreadLink } from "@t3tools/shared/threadLinks"; import { resolveThreadCurrentPullRequestLink } from "./threadPullRequests.ts"; export interface ThreadReferenceCopyTarget { readonly kind: "pull-request" | "thread"; - /** The plain text: a PR URL, or a thread chip's reference link. */ + /** The plain text: a PR URL, or a link that opens the thread from anywhere. */ readonly value: string; /** What a toast or palette row shows for the copy. */ readonly description: string; @@ -23,15 +21,19 @@ export interface ThreadReferenceCopyTarget { readonly failureTitle: string; } -/** A thread chip pastes into any composer whose agent reads that thread's machine. */ +/** + * A link that opens the thread from anywhere, through `hostedAppUrl`, this build's hosted app. It + * pastes back as a chip into any composer whose agent reads that thread's machine. + */ export function threadChipCopyTarget( threadRef: ScopedThreadRef, title: string, + hostedAppUrl?: string, ): ThreadReferenceCopyTarget { const record = threadContextRecord(threadRef, title); return { kind: "thread", - value: formatComposerContextReference(record), + value: hostedThreadLink(threadRef, hostedAppUrl), description: record.title, fragment: { version: 1, source: { environmentId: threadRef.environmentId }, records: [record] }, clipboardTarget: "thread", @@ -47,6 +49,7 @@ export function resolveThreadReferenceCopyTarget(input: { readonly openPanelPullRequestUrl?: string | null | undefined; readonly pullRequests?: ReadonlyArray | undefined; readonly linkedPullRequestUrl?: string | null; + readonly hostedAppUrl?: string; }): ThreadReferenceCopyTarget | null { if (input.openPanelPullRequestUrl === null) return null; const pullRequestUrl = @@ -62,5 +65,5 @@ export function resolveThreadReferenceCopyTarget(input: { successTitle: "MR link copied", failureTitle: "Failed to copy MR link", } - : threadChipCopyTarget(input.threadRef, input.title); + : threadChipCopyTarget(input.threadRef, input.title, input.hostedAppUrl); } diff --git a/packages/client-runtime/src/work-log/itemDetail.test.ts b/packages/client-runtime/src/work-log/itemDetail.test.ts index e1511170800e0..cfa00db4f8f81 100644 --- a/packages/client-runtime/src/work-log/itemDetail.test.ts +++ b/packages/client-runtime/src/work-log/itemDetail.test.ts @@ -2,7 +2,26 @@ import { describe, expect, it } from "vite-plus/test"; import { ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import { turnItemOutputImages, turnItemOutputText } from "./itemDetail.ts"; +import { isJsonToolText, turnItemOutputImages, turnItemOutputText } from "./itemDetail.ts"; + +describe("isJsonToolText", () => { + it.each([ + ['{"screenshot":{"mimeType":"image/png"}}', true], + ['[{"threadId":"thread-1"}]', true], + ['{\n\n "status": "completed"\n}', true], + ['{\n "count": 1\n}\n\n{\n "count": 2\n}', true], + ["false", true], + ["null", true], + ["42", true], + ['"completed"', true], + ['{"partial":', false], + ['{"count":1}\n\nFinished.', false], + ["No output.", false], + ["", false], + ] as const)("classifies %j as JSON: %s", (text, expected) => { + expect(isJsonToolText(text)).toBe(expected); + }); +}); function toolItem(output: unknown): OrchestrationV2TurnItem { return { @@ -28,15 +47,54 @@ function toolItem(output: unknown): OrchestrationV2TurnItem { } describe("tool image output parsing", () => { - it.each(["array", "content"] as const)( + it.each(["image-only", "summary", "json"] as const)( + "keeps structured fields beside %s content", + (shape) => { + const content = [ + { type: "image", mimeType: "image/png" }, + ...(shape === "image-only" + ? [] + : [{ type: "text", text: shape === "summary" ? "Sunny" : '{"temp":21}' }]), + ]; + for (const structuredContent of [ + { temp: 21 }, + { type: "text", text: "Sunny", temp: 21 }, + { content: [{ type: "text", text: "Sunny" }], temp: 21 }, + ]) { + const item = toolItem({ structuredContent, content }); + expect(turnItemOutputText(item)).toBe(JSON.stringify(structuredContent, null, 2)); + expect(turnItemOutputImages(item)).toHaveLength(1); + } + for (const structuredContent of [null, {}, [], ""]) { + const item = toolItem({ structuredContent, content: [{ type: "text", text: "Sunny" }] }); + expect(turnItemOutputText(item)).toBe("Sunny"); + } + expect( + turnItemOutputText( + toolItem({ + content: [{ type: "text", text: "File contents" }], + structuredContent: { path: "read.ts", ready: true }, + }), + ), + ).toBe("File contents"); + }, + ); + it.each(["array", "content", "structured"] as const)( "keeps markers for images beyond the served positions in %s output", (shape) => { // Detail responses retain image metadata after the server strips image bytes. const image = { type: "image", mimeType: "image/png" }; const blocks = Array.from({ length: 8 }, () => ({ ...image })); - const wrap = (content: unknown[]) => (shape === "array" ? content : { content }); + const wrap = (content: unknown[]) => + shape === "array" + ? content + : shape === "structured" + ? { content, structuredContent: { temp: 21 } } + : { content }; const withinLimit = toolItem(wrap(blocks)); - expect(turnItemOutputText(withinLimit)).toBeNull(); + expect(turnItemOutputText(withinLimit)).toBe( + shape === "structured" ? '{\n "temp": 21\n}' : null, + ); expect(turnItemOutputImages(withinLimit)).toHaveLength(8); const overflow = toolItem( @@ -48,7 +106,10 @@ describe("tool image output parsing", () => { ]), ); expect(turnItemOutputImages(overflow)).toHaveLength(8); - expect(turnItemOutputText(overflow)).toBe("Two additional screenshots:\n[image]\n[image]"); + expect(turnItemOutputText(overflow)).toBe( + (shape === "structured" ? '{\n "temp": 21\n}' : "Two additional screenshots:") + + "\n[image]\n[image]", + ); }, ); }); diff --git a/packages/client-runtime/src/work-log/itemDetail.ts b/packages/client-runtime/src/work-log/itemDetail.ts index 5e16cf538ca98..16f79bd8d98c3 100644 --- a/packages/client-runtime/src/work-log/itemDetail.ts +++ b/packages/client-runtime/src/work-log/itemDetail.ts @@ -39,6 +39,14 @@ function textFromBlocks( return textFromBlocks(value.content, depth + 1, imageBlocks); if (keys.length === 2 && keys.includes("content") && keys.includes("structuredContent")) { const text = textFromBlocks(value.content, depth + 1, imageBlocks); + const images = Array.isArray(value.content) + ? value.content.filter((block) => isRecord(block) && block.type === "image") + : []; + const structured = formatJsonValue(value.structuredContent); + if (structured !== null && (images.length > 0 || !text?.trim())) { + const markers = textFromBlocks(images, depth + 1, imageBlocks); + return [structured, markers].filter(Boolean).join("\n"); + } return text?.trim() ? text : null; } return null; @@ -52,6 +60,12 @@ function parseJson(text: string): unknown { } } +export function isJsonToolText(text: string): boolean { + if (parseJson(text) !== undefined) return true; + const documents = text.trim().split(/\n\s*\n/); + return documents.length > 1 && documents.every((document) => parseJson(document) !== undefined); +} + function prettyJsonText(text: string): string { const trimmed = text.trim(); if (!/^[[{]/.test(trimmed)) return text; @@ -63,11 +77,9 @@ function prettyJsonText(text: string): string { return documents.map((document) => JSON.stringify(document, null, 2)).join("\n\n"); } -function formatToolValue(value: unknown, hasImagePreview = false): string | null { +function formatJsonValue(value: unknown): string | null { if (value === undefined || value === null) return null; - if (hasImagePreview && isRecord(value) && value.type === "image") return null; - const text = textFromBlocks(value, 0, toolOutputImageBlocks(value)); - if (text !== null) return text.trim() ? prettyJsonText(text) : null; + if (typeof value === "string" && !value.trim()) return null; let json: string | undefined; try { json = JSON.stringify(value, null, 2); @@ -78,6 +90,14 @@ function formatToolValue(value: unknown, hasImagePreview = false): string | null return json; } +function formatToolValue(value: unknown, hasImagePreview = false): string | null { + if (value === undefined || value === null) return null; + if (hasImagePreview && isRecord(value) && value.type === "image") return null; + const text = textFromBlocks(value, 0, toolOutputImageBlocks(value)); + if (text !== null) return text.trim() ? prettyJsonText(text) : null; + return formatJsonValue(value); +} + export function toolCallLines(input: { readonly command?: string | undefined; readonly args?: unknown; diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index c0ecccca0db5e..ee71287ea9217 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -309,12 +309,31 @@ function workEntryIndicatesToolFailureFromOutput( return output.length > 0 && toolOutputIndicatesFailure(output); } +// Rows are re-derived on every timeline update but entries are reused while +// their item is unchanged, so these per-entry checks are computed once. +const entryChecks = new WeakMap< + WorkLogPresentationEntry, + { failure?: boolean; displayFailure?: boolean; action?: ToolGroupAction } +>(); + +function checksFor(entry: WorkLogPresentationEntry) { + let checks = entryChecks.get(entry); + if (!checks) { + checks = {}; + entryChecks.set(entry, checks); + } + return checks; +} + export function workEntryIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean { - return workEntryIndicatesToolFailureFromOutput(entry, true); + return (checksFor(entry).failure ??= workEntryIndicatesToolFailureFromOutput(entry, true)); } export function workEntryDisplayIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean { - return workEntryIndicatesToolFailureFromOutput(entry, false); + return (checksFor(entry).displayFailure ??= workEntryIndicatesToolFailureFromOutput( + entry, + false, + )); } function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean { @@ -326,6 +345,10 @@ function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean } export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction { + return (checksFor(entry).action ??= computeToolGroupAction(entry)); +} + +function computeToolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction { if (entry.itemType === "thread_created") return "thread-create"; const presentation = resolveWorkEntryToolPresentation(entry); if (presentation?.action !== undefined) return presentation.action; diff --git a/packages/coder-cli/package.json b/packages/coder-cli/package.json index a4319b7663242..d2d67bcde9ff4 100644 --- a/packages/coder-cli/package.json +++ b/packages/coder-cli/package.json @@ -41,7 +41,7 @@ } }, "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "effect-tsgo tsrs --noEmit", "test": "node --test src/*.test.ts" }, "dependencies": { diff --git a/packages/coder-cli/src/helperConnection.test.ts b/packages/coder-cli/src/helperConnection.test.ts index f18f45d262b6a..6cd9cc8bedde9 100644 --- a/packages/coder-cli/src/helperConnection.test.ts +++ b/packages/coder-cli/src/helperConnection.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalTimers:off -- Coder: waits in real time for fake helper stdio traffic. // @effect-diagnostics nodeBuiltinImport:off import { deepStrictEqual, rejects, strictEqual } from "node:assert"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; diff --git a/packages/coder-cli/src/helperConnection.ts b/packages/coder-cli/src/helperConnection.ts index fec9cff822a01..8bf7a0d40a1db 100644 --- a/packages/coder-cli/src/helperConnection.ts +++ b/packages/coder-cli/src/helperConnection.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics globalTimers:off -- Coder: negotiation and stderr-report timers run in Node stream callbacks. +// @effect-diagnostics globalJsonInEffect:off -- Coder: encodes RPC frames for the helper's newline-delimited stdio. // @effect-diagnostics nodeBuiltinImport:off -- This module adapts Node child processes to Effect. import { spawn, @@ -40,6 +42,7 @@ const PREFLIGHT_TIMEOUT_MESSAGE = const DEFAULT_TERMINATION_GRACE_MS = 5_000; const NEGOTIATION_TIMEOUT_MESSAGE = "Timed out while negotiating with the Coder workspace helper."; +// @effect-diagnostics-next-line extendsNativeError:off -- Coder: native error the gateway matches with instanceof and renders by message at the HTTP boundary. export class CoderHelperConnectionError extends Error { readonly _tag = "CoderHelperConnectionError"; diff --git a/packages/coder-cli/src/portForward.ts b/packages/coder-cli/src/portForward.ts index 7de1c0df1fb28..e94c6451944e0 100644 --- a/packages/coder-cli/src/portForward.ts +++ b/packages/coder-cli/src/portForward.ts @@ -11,6 +11,7 @@ import { terminateChildProcess } from "./processTermination.ts"; const MAX_ERROR_BYTES = 16 * 1024; const DEFAULT_TERMINATION_GRACE_MS = 5_000; +// @effect-diagnostics-next-line extendsNativeError:off -- Coder: native error the gateway matches with instanceof and renders by message at the HTTP boundary. export class CoderPortForwardError extends Error { readonly _tag = "CoderPortForwardError"; diff --git a/packages/coder-cli/src/processTermination.ts b/packages/coder-cli/src/processTermination.ts index 770bcf5ca4335..019352466a270 100644 --- a/packages/coder-cli/src/processTermination.ts +++ b/packages/coder-cli/src/processTermination.ts @@ -5,6 +5,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +// @effect-diagnostics-next-line extendsNativeError:off -- Coder: native error the gateway matches with instanceof and renders by message at the HTTP boundary. export class CoderProcessExitUnconfirmedError extends Error { readonly _tag = "CoderProcessExitUnconfirmedError"; @@ -47,6 +48,7 @@ export const terminateChildProcess = (input: { Effect.gen(function* () { if (exited()) return; if (input.requestExit !== undefined) { + // @effect-diagnostics-next-line tryCatchInEffectGen:off -- Coder: a throwing exit request falls back to signals; termination must not fail. try { input.requestExit(); } catch { diff --git a/packages/coder-cli/src/transfer.test.ts b/packages/coder-cli/src/transfer.test.ts index 8027c7a8e243a..d770c556acc87 100644 --- a/packages/coder-cli/src/transfer.test.ts +++ b/packages/coder-cli/src/transfer.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off -- Coder: asserts wall-clock transfer timing. // @effect-diagnostics nodeBuiltinImport:off import { match, rejects, strictEqual } from "node:assert"; import { existsSync } from "node:fs"; diff --git a/packages/coder-cli/src/transfer.ts b/packages/coder-cli/src/transfer.ts index a1d5badbd3c76..1ad045e73137c 100644 --- a/packages/coder-cli/src/transfer.ts +++ b/packages/coder-cli/src/transfer.ts @@ -24,6 +24,7 @@ const DEFAULT_TERMINATION_GRACE_MS = 5_000; const TRANSFER_READY_SENTINEL = "T3_CODER_TRANSFER_READY"; const TAR_BLOCK_BYTES = 512; +// @effect-diagnostics-next-line extendsNativeError:off -- Coder: native error the gateway matches with instanceof and renders by message at the HTTP boundary. export class CoderProcessError extends Error { readonly _tag = "CoderProcessError"; @@ -101,7 +102,7 @@ export function runProcess( }), (child) => terminateChildProcess({ child, exit, graceMs: terminationGraceMs }).pipe( - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, ), ); diff --git a/packages/contracts/package.json b/packages/contracts/package.json index e30e525449712..1767e1ac9aa3e 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.1.6", + "version": "0.1.7", "private": true, "files": [ "dist" @@ -17,7 +17,7 @@ } }, "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "effect-tsgo tsrs --noEmit", "test": "vp test run --config ../../vite.config.ts --dir ." }, "dependencies": { diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index d3139f2eeff24..91db78715551e 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -20,6 +20,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.previewStopLocalServer]: AuthTerminalOperateScope, [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, + [WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, @@ -36,6 +37,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.projectsCreateFromThread]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, + [WS_METHODS.sourceControlStar]: AuthSourceControlWriteScope, // Signing in replaces the server's GitHub credential, as saving a token in Settings does. [WS_METHODS.sourceControlSignInStart]: AuthSettingsWriteScope, [WS_METHODS.sourceControlSignInWait]: AuthSettingsWriteScope, diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 24b72dbe26d7a..417d1c81b418e 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { ThreadId, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { SettingsSecretValue } from "./settingsSecretValue.ts"; export const DevicePlatform = Schema.Literals(["ios", "android"]); export type DevicePlatform = typeof DevicePlatform.Type; @@ -40,11 +41,11 @@ export const EasDeviceHostConfig = Schema.Struct({ /** EAS project that sessions run under, when the project's app config does not name one. */ appId: Schema.optional(TrimmedNonEmptyString), /** - * Expo access token, kept in the secret store. When empty the server uses - * EXPO_TOKEN, then the `eas login` session. This is `secretSetting` from - * settings.ts, which imports this module. + * Expo access token, kept in the secret store, or the secret provider to read + * it from. When empty the server uses EXPO_TOKEN, then the `eas login` session. + * This is `secretSetting` from settings.ts, which imports this module. */ - accessToken: TrimmedString.pipe( + accessToken: SettingsSecretValue.pipe( Schema.withDecodingDefaultKey(Effect.succeed("")), Schema.annotateKey({ settingsSecret: true }), ), @@ -116,7 +117,7 @@ export const SimlockDeviceHostConfigs = Schema.Array(SimlockDeviceHostConfig).ch export const DeviceEasAccountInput = Schema.Struct({ /** A saved EAS host whose stored token is checked when no new token is given. */ hostId: Schema.optional(DeviceHostId), - accessToken: Schema.optional(TrimmedString), + accessToken: Schema.optional(SettingsSecretValue), }); export type DeviceEasAccountInput = typeof DeviceEasAccountInput.Type; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index f9cd98311d19d..9ac89ef499b3f 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -15,6 +15,11 @@ export const ORCHESTRATION_PROTOCOL_VERSION_TEXT = "2"; export const ORCHESTRATION_PROTOCOL_QUERY_PARAM = "orchestrationProtocol"; export const ORCHESTRATION_PROTOCOL_HEADER = "x-t3-orchestration-protocol"; +/** Wire shapes a client understands, comma-separated; a client that omits one gets the older shape. */ +export const CLIENT_CAPABILITIES_QUERY_PARAM = "clientCapabilities"; +/** Secret fields may hold a secret provider reference instead of a string. */ +export const SETTINGS_SECRET_REFERENCES_CAPABILITY = "settingsSecretReferences"; + export const ExecutionEnvironmentPlatformOs = Schema.Literals([ "darwin", "linux", diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index 7270ba78c9acc..a1f6c293e90b5 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -64,6 +64,8 @@ export class FilesystemBrowseError extends Schema.TaggedError> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [FX_DRIVER_KIND]: "fx", + [KIRO_DRIVER_KIND]: "Kiro", [MUSE_DRIVER_KIND]: "Muse Code", [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", [PI_DRIVER_KIND]: "Pi", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 2767fd83cbc34..f9731404df1e4 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -285,6 +285,8 @@ export const OrchestrationV2TurnCapabilities = Schema.Struct({ supportsActiveSteering: Schema.Boolean, // Some native steering mechanisms cancel pending tools before consuming the message. activeSteeringInterruptsTools: Schema.optional(Schema.Boolean), + // Some take text only, so a steer with attachments restarts the turn instead. + activeSteeringAcceptsAttachments: Schema.optional(Schema.Boolean), supportsSteeringByInterruptRestart: Schema.Boolean, supportsQueuedMessages: Schema.Boolean, terminalStatusQuality: Schema.Literals(["strong", "weak", "none"]), @@ -3588,6 +3590,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { searchThreadStream: "orchestration.searchThreadStream", getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", getThreadFamily: "orchestration.getThreadFamily", + getThreadTitles: "orchestration.getThreadTitles", getWorktreeThreadIds: "orchestration.getWorktreeThreadIds", getOlderSettledThreads: "orchestration.getOlderSettledThreads", getThreadProjection: "orchestration.getThreadProjection", @@ -3712,6 +3715,13 @@ export const OrchestrationV2ThreadFamily = Schema.Struct({ }); export type OrchestrationV2ThreadFamily = typeof OrchestrationV2ThreadFamily.Type; +export const OrchestrationV2GetThreadTitlesInput = Schema.Struct({ + threadIds: Schema.Array(ThreadId), +}); + +/** Titles of the requested threads that exist, archived ones included. Deleted and unknown ids are absent. */ +export const OrchestrationV2ThreadTitles = Schema.Record(ThreadId, Schema.String); + export const OrchestrationV2GetWorktreeThreadIdsInput = Schema.Struct({ worktreePath: TrimmedNonEmptyString, }); @@ -4234,6 +4244,10 @@ export const OrchestrationV2RpcSchemas = { input: OrchestrationV2GetThreadFamilyInput, output: OrchestrationV2ThreadFamily, }, + getThreadTitles: { + input: OrchestrationV2GetThreadTitlesInput, + output: OrchestrationV2ThreadTitles, + }, getWorktreeThreadIds: { input: OrchestrationV2GetWorktreeThreadIdsInput, output: OrchestrationV2WorktreeThreadIds, diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 438a3fe2def98..33ca91be6c86e 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -725,6 +725,11 @@ export const OrchestratorMcpUpdateScheduledTaskInput = Schema.Struct({ export type OrchestratorMcpUpdateScheduledTaskInput = typeof OrchestratorMcpUpdateScheduledTaskInput.Type; +// Not trimmed: whitespace in a password matters. The check stays out of the advertised schema. +const PastedSecretValue = Schema.String.check( + Schema.makeFilter((value: string) => value.length > 0, { expected: "a non-empty string" }), +); + export const OrchestratorMcpRequestSecretInput = Schema.Struct({ label: TrimmedNonEmptyString.annotate({ description: "Card title, such as 'GitHub webhook secret'.", @@ -734,14 +739,17 @@ export const OrchestratorMcpRequestSecretInput = Schema.Struct({ }), placeholder: Schema.optional(TrimmedNonEmptyString), delivery: Schema.optional(Schema.Literals(["secretRef", "file"])).annotate({ - description: - 'secretRef (default) for a T3 tool. file for a shell CLI: read the path in your command, e.g. TOKEN="$(cat )" wrangler whoami.', + description: "secretRef (default) for a T3 tool; file for a command.", }), + value: Schema.optional(PastedSecretValue).annotate({ + description: "Pasted in this thread; no card.", + }), + /** Default 10 minutes. */ timeoutMs: Schema.optional( Schema.Int.check(Schema.isBetween({ minimum: 1_000, maximum: 60 * 60 * 1_000 })), - ).annotate({ description: "Default 10 min." }), + ), clientRequestId: Schema.optional(OrchestratorMcpClientRequestId).annotate({ - description: "Retry key for this ask. New key after a timeout or cancel.", + description: "Retry key.", }), }); export type OrchestratorMcpRequestSecretInput = typeof OrchestratorMcpRequestSecretInput.Type; @@ -763,8 +771,9 @@ export const OrchestratorMcpRequestSecretResult = Schema.Union([ Schema.Struct({ status: Schema.Literals(["declined", "cancelled", "timed_out"]).annotate({ description: - "declined: user chose not to. cancelled: ended with the run. timed_out: no answer in time. Ask again with a new `clientRequestId` if still needed.", + "declined: user chose not to; don't ask again. cancelled: ended with the run. timed_out: no answer in time. After cancelled or timed_out, ask again with a new `clientRequestId` only if still needed.", }), + note: Schema.optional(Schema.String).annotate({ description: "What to do next." }), }), ]); export type OrchestratorMcpRequestSecretResult = typeof OrchestratorMcpRequestSecretResult.Type; diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index cbf8ac60b2690..1790a4797db29 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -376,7 +376,7 @@ export class PreviewInvalidUrlError extends Schema.TaggedError from the latest snapshot or a Playwright role/text selector; put CSS in selector." + : "selector takes CSS; use locator for aria-ref=, role, or text targets."; + return `Preview automation ${this.operation} received an invalid ${this.selectorKind} (${this.selectorLength} characters). ${guidance}`; } return `Preview automation ${this.operation} received an invalid selector.`; } diff --git a/packages/contracts/src/review.test.ts b/packages/contracts/src/review.test.ts index 996f9b248b776..be8f42814413f 100644 --- a/packages/contracts/src/review.test.ts +++ b/packages/contracts/src/review.test.ts @@ -9,7 +9,7 @@ import { describe("ReviewDiffFileError", () => { it("decodes an oversized-file failure as a typed error", () => { - const error = Schema.decodeUnknownSync(ReviewDiffFileError)({ + const error = Schema.decodeSync(ReviewDiffFileError)({ _tag: "ReviewDiffFileTooLargeError", path: "src/large.ts", maxBytes: MAX_REVIEW_DIFF_FILE_BYTES, @@ -22,7 +22,7 @@ describe("ReviewDiffFileError", () => { it("rejects a non-positive expansion limit", () => { expect(() => - Schema.decodeUnknownSync(ReviewDiffFileError)({ + Schema.decodeSync(ReviewDiffFileError)({ _tag: "ReviewDiffFileTooLargeError", path: "src/large.ts", maxBytes: 0, @@ -34,14 +34,14 @@ describe("ReviewDiffFileError", () => { describe("ReviewDiffFileSnapshotResult", () => { it("decodes opened and oversized outcomes", () => { expect( - Schema.decodeUnknownSync(ReviewDiffFileSnapshotResult)({ + Schema.decodeSync(ReviewDiffFileSnapshotResult)({ _tag: "opened", oldFile: null, newFile: null, }), ).toEqual({ _tag: "opened", oldFile: null, newFile: null }); expect( - Schema.decodeUnknownSync(ReviewDiffFileSnapshotResult)({ + Schema.decodeSync(ReviewDiffFileSnapshotResult)({ _tag: "tooLarge", path: "src/large.ts", maxBytes: MAX_REVIEW_DIFF_FILE_BYTES, @@ -55,7 +55,7 @@ describe("ReviewDiffFileSnapshotResult", () => { it("rejects an invalid oversized outcome", () => { expect(() => - Schema.decodeUnknownSync(ReviewDiffFileSnapshotResult)({ + Schema.decodeSync(ReviewDiffFileSnapshotResult)({ _tag: "tooLarge", path: "src/large.ts", maxBytes: 0, diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts index 1948bc122d293..fb7d06acacc4f 100644 --- a/packages/contracts/src/review.ts +++ b/packages/contracts/src/review.ts @@ -3,25 +3,19 @@ import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchema import { GitCommandError } from "./git.ts"; import { VcsError } from "./vcs.ts"; -export const ReviewDiffPreviewSourceKind = Schema.Literals([ - "working-tree", - "branch-range", - "commit", -]); -export type ReviewDiffPreviewSourceKind = typeof ReviewDiffPreviewSourceKind.Type; - export const ReviewDiffPreviewInput = Schema.Struct({ cwd: TrimmedNonEmptyString, baseRef: Schema.optional(TrimmedNonEmptyString), commitSha: Schema.optional(TrimmedNonEmptyString), ignoreWhitespace: Schema.optionalKey(Schema.Boolean), - // Coder: a branch preview may ask for one top-level source. - sourceKind: Schema.optionalKey(ReviewDiffPreviewSourceKind), + sourceKind: Schema.optionalKey(Schema.Literals(["working-tree", "branch-range"])), + trackedOnly: Schema.optionalKey(Schema.Boolean), + metadataOnly: Schema.optionalKey(Schema.Boolean), file: Schema.optionalKey( Schema.Struct({ path: Schema.NonEmptyString, previousPath: Schema.NullOr(Schema.NonEmptyString), - sourceKind: ReviewDiffPreviewSourceKind, + sourceKind: Schema.Literals(["working-tree", "branch-range", "commit"]), }), ), }); @@ -45,6 +39,13 @@ export const ReviewBranchCommitsResult = Schema.Struct({ }); export type ReviewBranchCommitsResult = typeof ReviewBranchCommitsResult.Type; +export const ReviewDiffPreviewSourceKind = Schema.Literals([ + "working-tree", + "branch-range", + "commit", +]); +export type ReviewDiffPreviewSourceKind = typeof ReviewDiffPreviewSourceKind.Type; + export const ReviewDiffFileStat = Schema.Struct({ path: Schema.String, previousPath: Schema.NullOr(Schema.String), @@ -63,6 +64,7 @@ export const ReviewDiffPreviewSource = Schema.Struct({ diff: Schema.String, diffHash: TrimmedNonEmptyString, truncated: Schema.Boolean, + patchesDeferred: Schema.optionalKey(Schema.Boolean), files: Schema.optionalKey(Schema.Array(ReviewDiffFileStat)), }); export type ReviewDiffPreviewSource = typeof ReviewDiffPreviewSource.Type; @@ -142,6 +144,7 @@ export const ReviewDiffPreviewResult = Schema.Struct({ cwd: TrimmedNonEmptyString, generatedAt: Schema.DateTimeUtc, sources: Schema.Array(ReviewDiffPreviewSource), + incomplete: Schema.optionalKey(Schema.Boolean), }); export type ReviewDiffPreviewResult = typeof ReviewDiffPreviewResult.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 27eb258bd7792..316054ed10366 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -581,6 +581,7 @@ export const WS_METHODS = { sourceControlSignInCancel: "sourceControl.signInCancel", sourceControlAppAccess: "sourceControl.appAccess", sourceControlStarred: "sourceControl.starred", + sourceControlStar: "sourceControl.star", projectCloneStart: "projectClone.start", projectCloneCancel: "projectClone.cancel", projectCloneRetry: "projectClone.retry", @@ -1127,6 +1128,13 @@ const WsSourceControlStarredRpc = Rpc.make(WS_METHODS.sourceControlStarred, { error: EnvironmentAuthorizationError, }); +// Stars the repository as the server's forge account. False when the forge refused or cannot star. +const WsSourceControlStarRpc = Rpc.make(WS_METHODS.sourceControlStar, { + payload: SourceControlStarredInput, + success: Schema.Boolean, + error: EnvironmentAuthorizationError, +}); + // Returns when the project exists and cloning starts; progress uses the subscription. const WsProjectCloneStartRpc = Rpc.make(WS_METHODS.projectCloneStart, { payload: ProjectCloneStartInput, @@ -1627,6 +1635,12 @@ const WsOrchestrationV2GetThreadFamilyRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS error: Schema.Union([OrchestrationV2GetShellSnapshotError, EnvironmentAuthorizationError]), }); +const WsOrchestrationV2GetThreadTitlesRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.getThreadTitles, { + payload: OrchestrationV2RpcSchemas.getThreadTitles.input, + success: OrchestrationV2RpcSchemas.getThreadTitles.output, + error: Schema.Union([OrchestrationV2GetShellSnapshotError, EnvironmentAuthorizationError]), +}); + const WsOrchestrationV2GetWorktreeThreadIdsRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds, { @@ -1920,6 +1934,7 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlSignInCancelRpc, WsSourceControlAppAccessRpc, WsSourceControlStarredRpc, + WsSourceControlStarRpc, WsProjectCloneStartRpc, WsProjectCloneCancelRpc, WsProjectCloneRetryRpc, @@ -2008,6 +2023,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationV2SearchThreadStreamRpc, WsOrchestrationV2GetArchivedShellSnapshotRpc, WsOrchestrationV2GetThreadFamilyRpc, + WsOrchestrationV2GetThreadTitlesRpc, WsOrchestrationV2GetWorktreeThreadIdsRpc, WsOrchestrationV2GetOlderSettledThreadsRpc, WsOrchestrationV2GetThreadProjectionRpc, @@ -2240,6 +2256,7 @@ export const CoderWsRpcGroup = RpcGroup.make( WsOrchestrationV2SearchThreadStreamRpc, WsOrchestrationV2GetArchivedShellSnapshotRpc, WsOrchestrationV2GetThreadFamilyRpc, + WsOrchestrationV2GetThreadTitlesRpc, WsOrchestrationV2GetWorktreeThreadIdsRpc, WsOrchestrationV2GetOlderSettledThreadsRpc, WsOrchestrationV2GetThreadProjectionRpc, diff --git a/packages/contracts/src/secretRequest.ts b/packages/contracts/src/secretRequest.ts index 2506d064b533c..b449a17643d40 100644 --- a/packages/contracts/src/secretRequest.ts +++ b/packages/contracts/src/secretRequest.ts @@ -13,6 +13,16 @@ export const SecretRequestAnswerInput = Schema.Struct({ }); export type SecretRequestAnswerInput = typeof SecretRequestAnswerInput.Type; +/** What the agent reads when its card ends without a secret. */ +export const SECRET_REQUEST_UNSAVED_NOTE = { + declined: + "The user declined. Don't ask for it in chat or another way; say what's blocked and go on without it.", + cancelled: + "The card closed with the run. Ask again with a new clientRequestId only if the secret is still needed.", + timed_out: + "No answer in time. Don't ask in chat; ask again with a new clientRequestId only if it is still needed.", +} as const; + const SECRET_REQUEST_FAILURE_MESSAGES = { load_failed: "Could not load the secret request.", not_found: "This secret request no longer exists.", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2f11602d085ab..e154184f02a26 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -22,6 +22,7 @@ import { } from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; import { SecretProviderId, SecretProviderSlug } from "./secretReference.ts"; +import { SettingsSecretValue } from "./settingsSecretValue.ts"; import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { @@ -872,7 +873,10 @@ export const UsageLimitSourceConfig = Schema.Struct({ kind: Schema.Literal("cliproxy"), label: Schema.optional(TrimmedNonEmptyString), url: TrimmedNonEmptyString, - managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("")), secretSetting), + managementKey: SettingsSecretValue.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + secretSetting, + ), enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; @@ -1336,6 +1340,8 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(null)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + /** Clones default to `///`, like ghq. */ + ghqCloneLayout: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ @@ -1418,8 +1424,8 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un // Must match each driver's enabled decoding default. // Coder: Kilo Code's ACP Registry settings default to disabled, as Pi's do. const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet = new Set( - ["cursor", "grok", "fx", "muse", "pi", "opencode", "antigravity", "acpRegistry"].map((driver) => - ProviderDriverKind.make(driver), + ["cursor", "grok", "fx", "kiro", "muse", "pi", "opencode", "antigravity", "acpRegistry"].map( + (driver) => ProviderDriverKind.make(driver), ), ); @@ -1677,6 +1683,7 @@ export const ServerSettingsPatch = Schema.Struct({ newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), + ghqCloneLayout: Schema.optionalKey(Schema.Boolean), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), threadTitleInstructions: Schema.optionalKey(TrimmedString), branchNamingMode: Schema.optionalKey(BranchNamingMode), @@ -1702,7 +1709,7 @@ export const ServerSettingsPatch = Schema.Struct({ otlpLogsUrl: Schema.optionalKey(TrimmedString), }), ), - /** Merges per host, replacing patched fields whole; redaction markers retain secrets, empty or omitted secrets remove them. */ + /** Merges per host, replacing patched fields whole; redaction markers retain secrets, empty secrets remove them. A non-empty token map keeps the saved tokens of hosts it omits; an empty map clears them all. */ sourceControlHosts: Schema.optionalKey(Schema.Record(Schema.String, SourceControlHostSettings)), // Replace whole maps to avoid half-merged driver configs. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), diff --git a/packages/contracts/src/settingsSecretValue.ts b/packages/contracts/src/settingsSecretValue.ts index 101c40998b6f0..97558c82d574f 100644 --- a/packages/contracts/src/settingsSecretValue.ts +++ b/packages/contracts/src/settingsSecretValue.ts @@ -26,26 +26,68 @@ export function acceptsSecretReference(schema: Schema.Top): boolean { return Schema.resolveAnnotationsKey(schema)?.settingsSecretReference === true; } -/** Fields of a settings struct typed `SettingsSecretValue`; any other schema has none. */ -function secretReferenceSettingsFields(schema: Schema.Top): ReadonlyArray { +const declaresSecretReference = (ast: SchemaAST.AST) => + ast.context?.annotations?.settingsSecretReference === true; + +interface SecretReferenceField { + readonly field: string; + readonly keyed: boolean; +} + +/** + * Fields of a settings struct typed `SettingsSecretValue`, or a record of it such as one value + * per host; any other schema has none. + */ +function secretReferenceSettingsFields(schema: Schema.Top): ReadonlyArray { if (!SchemaAST.isObjects(schema.ast)) return []; - return schema.ast.propertySignatures.flatMap((property) => - property.type.context?.annotations?.settingsSecretReference === true && - typeof property.name === "string" - ? [property.name] - : [], - ); + return schema.ast.propertySignatures.flatMap(({ name, type }) => { + if (typeof name !== "string") return []; + if (declaresSecretReference(type)) return [{ field: name, keyed: false }]; + return SchemaAST.isObjects(type) && + type.indexSignatures.some((signature) => declaresSecretReference(signature.type)) + ? [{ field: name, keyed: true }] + : []; + }); } -/** Each declared reference field of `settings` that holds a reference, in field order. */ +/** Where a reference sits: a field, or one key of a record field. */ +export interface SettingsSecretSlot { + readonly field: string; + readonly key: string | undefined; +} + +interface SettingsSecretReference extends SettingsSecretSlot { + readonly reference: SecretReference; +} + +/** Each declared reference slot of `settings` that holds a reference, in field order. */ export function secretReferencesInSettings( schema: Schema.Top, settings: unknown, -): ReadonlyArray<{ readonly field: string; readonly reference: SecretReference }> { +): ReadonlyArray { if (settings === null || typeof settings !== "object") return []; const values = settings as Readonly>; - return secretReferenceSettingsFields(schema).flatMap((field) => { - const value = values[field]; - return isSecretReference(value) ? [{ field, reference: value }] : []; - }); + return secretReferenceSettingsFields(schema).flatMap( + ({ field, keyed }) => { + const value = values[field]; + if (!keyed) + return isSecretReference(value) ? [{ field, key: undefined, reference: value }] : []; + if (value === null || typeof value !== "object") return []; + return Object.entries(value).flatMap(([key, entry]) => + isSecretReference(entry) ? [{ field, key, reference: entry }] : [], + ); + }, + ); +} + +/** `settings` with each slot set to its value, keeping the other keys of a record field. */ +export function withSettingsSecrets>>( + settings: Settings, + slots: ReadonlyArray, +): Settings { + const next: Record = { ...settings }; + for (const { field, key, value } of slots) { + next[field] = key === undefined ? value : { ...(next[field] as object), [key]: value }; + } + return next as Settings; } diff --git a/packages/contracts/src/streamParity.test.ts b/packages/contracts/src/streamParity.test.ts index 00873b85815db..ed327ad3fb5cd 100644 --- a/packages/contracts/src/streamParity.test.ts +++ b/packages/contracts/src/streamParity.test.ts @@ -11,20 +11,20 @@ import { ServerConfigStreamEvent } from "./server.ts"; describe("stream wire contracts", () => { it("keeps completion markers and bounded snapshots opt-in", () => { expect( - Schema.decodeUnknownSync(OrchestrationV2SubscribeShellInput)({ + Schema.decodeSync(OrchestrationV2SubscribeShellInput)({ requestCompletionMarker: true, }), ).toEqual({ requestCompletionMarker: true }); expect( - Schema.decodeUnknownSync(OrchestrationV2SubscribeThreadInput)({ + Schema.decodeSync(OrchestrationV2SubscribeThreadInput)({ threadId: "thread", requestCompletionMarker: true, acceptBoundedSnapshot: true, }), ).toEqual({ threadId: "thread", requestCompletionMarker: true, acceptBoundedSnapshot: true }); - expect( - Schema.decodeUnknownSync(OrchestrationV2ShellStreamItem)({ kind: "synchronized" }), - ).toEqual({ kind: "synchronized" }); + expect(Schema.decodeSync(OrchestrationV2ShellStreamItem)({ kind: "synchronized" })).toEqual({ + kind: "synchronized", + }); expect(() => Schema.decodeUnknownSync(OrchestrationV2ShellStreamItem)({ kind: "cursor", sequence: 1 }), ).toThrow(); diff --git a/packages/effect-acp/package.json b/packages/effect-acp/package.json index 0ceaf8e8af958..08a2eea4776c8 100644 --- a/packages/effect-acp/package.json +++ b/packages/effect-acp/package.json @@ -37,7 +37,7 @@ } }, "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "effect-tsgo tsrs --noEmit", "test": "vp test run --config ../../vite.config.ts --dir .", "generate": "node scripts/generate.ts" }, diff --git a/packages/effect-acp/src/agent.test.ts b/packages/effect-acp/src/agent.test.ts index 47211d54b2b84..68058c582d47c 100644 --- a/packages/effect-acp/src/agent.test.ts +++ b/packages/effect-acp/src/agent.test.ts @@ -256,12 +256,7 @@ it.effect("effect-acp agent keeps RPC ids within int32 and apart from extension Schema.fromJsonString(RequestPermissionRequest), ); const decodedExt = Schema.decodeEffect(Schema.fromJsonString(ExtRequest)); - const firstIsPermission = yield* decodedPermission(firstOutbound).pipe( - Effect.match({ - onFailure: () => false, - onSuccess: () => true, - }), - ); + const firstIsPermission = yield* decodedPermission(firstOutbound).pipe(Effect.isSuccess); const permissionRequest = firstIsPermission ? yield* decodedPermission(firstOutbound) diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 8a1cc36390bbd..0736cb369c994 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -718,12 +718,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { const decodedInitialize = Schema.decodeEffect(Schema.fromJsonString(InitializeRequest)); const decodedExt = Schema.decodeEffect(Schema.fromJsonString(ExtRequest)); - const firstIsInitialize = yield* decodedInitialize(firstOutbound).pipe( - Effect.match({ - onFailure: () => false, - onSuccess: () => true, - }), - ); + const firstIsInitialize = yield* decodedInitialize(firstOutbound).pipe(Effect.isSuccess); const initializeRequest = firstIsInitialize ? yield* decodedInitialize(firstOutbound) diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index eae3394a1653f..6eb9fe0ea3ca0 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -884,6 +884,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { .request("x/test", { hello: "world" }) .pipe(Effect.flip, Effect.orDie, Effect.forkScoped); yield* Queue.take(output); + yield* Queue.offer(input, encoder.encode('{"jsonrpc":"2.0","id":1,"result":')); yield* Queue.end(input); const error = yield* Deferred.await(termination); @@ -900,6 +901,27 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("answers a pending request from a final response without a trailing newline", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + const pending = yield* transport + .request("x/test", { hello: "world" }) + .pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer( + input, + encoder.encode(encodeUnknownJsonString({ jsonrpc: "2.0", id: 1, result: { ok: true } })), + ); + yield* Queue.end(input); + + assert.deepEqual(yield* Fiber.join(pending), { ok: true }); + }), + ); + it.effect("propagates the real child exit code when the input stream ends", () => Effect.gen(function* () { const handle = yield* makeHandle({ ACP_MOCK_EXIT_IMMEDIATELY_CODE: "7" }); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 8763e66361e12..c29656cd12e7a 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -180,6 +180,16 @@ const makeStrictNdJsonRpcParser = () => { buffer = chunk.slice(lastNewline + 1); return lines.flatMap((line) => codec.decode(line)); }, + // A truncated final line is left to the exit status, which explains a crash better. + end: (): ReadonlyArray => { + const line = buffer + decoder.decode(); + buffer = ""; + try { + return line.trim() === "" ? [] : codec.decode(line); + } catch { + return []; + } + }, encode: (response: Parameters[0]) => { const encoded = codec.encode(response); return encoded === undefined ? undefined : `${encoded}\n`; @@ -748,6 +758,19 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi ), ), ), + Effect.andThen( + Effect.suspend(() => + Effect.forEach( + ( + parser.end() as ReadonlyArray< + RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded + > + ).map(normalizeAcpJsonRpcError), + routeDecodedMessage, + { discard: true }, + ), + ), + ), // Anything that ends the reader, including a defect in a callback it runs, // terminates the connection so pending requests fail instead of hanging. Effect.matchCauseEffect({ diff --git a/packages/effect-acp/test/fixtures/acp-mock-peer.ts b/packages/effect-acp/test/fixtures/acp-mock-peer.ts index a75d6f5df1bc0..7509cd88457e1 100644 --- a/packages/effect-acp/test/fixtures/acp-mock-peer.ts +++ b/packages/effect-acp/test/fixtures/acp-mock-peer.ts @@ -23,6 +23,8 @@ const sessionId = "mock-session-1"; const v2Management = process.env.ACP_MOCK_V2_MANAGEMENT === "1"; const unknownVariants = process.env.ACP_MOCK_UNKNOWN_VARIANTS === "1"; const mcpOverAcp = process.env.ACP_MOCK_MCP_OVER_ACP === "1"; +const v2Diff = process.env.ACP_MOCK_V2_DIFF === "1"; +const badTypedRequest = process.env.ACP_MOCK_BAD_TYPED_REQUEST === "1"; const program = Effect.gen(function* () { const agent = yield* AcpAgent.AcpAgent; @@ -178,7 +180,7 @@ const program = Effect.gen(function* () { }, }); - if (process.env.ACP_MOCK_V2_DIFF === "1") { + if (v2Diff) { yield* agent.client.sessionUpdate({ sessionId, update: { @@ -211,7 +213,7 @@ const program = Effect.gen(function* () { }); yield* agent.client.extRequest("x/typed_request", { - message: process.env.ACP_MOCK_BAD_TYPED_REQUEST === "1" ? 123 : "hello from typed request", + message: badTypedRequest ? 123 : "hello from typed request", }); yield* agent.client.extNotification("x/typed_notification", { diff --git a/packages/effect-codex-app-server/package.json b/packages/effect-codex-app-server/package.json index 484d723afbc80..856850290e8c6 100644 --- a/packages/effect-codex-app-server/package.json +++ b/packages/effect-codex-app-server/package.json @@ -29,7 +29,7 @@ } }, "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "effect-tsgo tsrs --noEmit", "test": "vp test run --config ../../vite.config.ts --dir .", "generate": "node scripts/generate.ts" }, diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 8f9a45bb9293c..343738c800c83 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -170,6 +170,22 @@ const DefinitionOverrides: Record = { }, }; +// Codex sends error codes this ref does not list yet (e.g. `modelNotFound`). A closed enum +// fails the whole notification decode, which drops a failed `turn/completed` and leaves the turn running. +const OpenStringEnumDefinitions = new Set(["CodexErrorInfo"]); + +function openStringEnum(schema: Schema.Json): Schema.Json { + if (!isJsonSchemaNode(schema) || !Array.isArray(schema.oneOf)) return schema; + return { + ...schema, + oneOf: schema.oneOf.map((variant: Schema.Json) => + isJsonSchemaNode(variant) && variant.type === "string" && Array.isArray(variant.enum) + ? { type: "string" } + : variant, + ), + }; +} + const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () { const path = yield* Path.Path; const generatedDir = path.join(import.meta.dirname, "..", "src", "_generated"); @@ -187,12 +203,13 @@ const ensureGeneratedDir = Effect.fn("ensureGeneratedDir")(function* () { yield* fs.makeDirectory(generatedDir, { recursive: true }); }); +const githubToken = process.env.GITHUB_TOKEN; + const fetchText = Effect.fn("fetchText")(function* (url: string) { - const token = process.env.GITHUB_TOKEN; return yield* HttpClientRequest.get(url).pipe( HttpClientRequest.setHeader("user-agent", USER_AGENT), - token && url.startsWith("https://api.github.com/") - ? HttpClientRequest.bearerToken(token) + githubToken && url.startsWith("https://api.github.com/") + ? HttpClientRequest.bearerToken(githubToken) : (request) => request, HttpClient.execute, Effect.flatMap(HttpClientResponse.filterStatusOk), @@ -662,7 +679,10 @@ const generateFiles = Effect.fn("generateFiles")(function* () { for (const [definitionName, definitionSchema] of Object.entries(parsed.definitions ?? {})) { aggregateSchemas[localDefinitionNames.get(definitionName)!] = pipe( rewriteExternalRefs( - DefinitionOverrides[definitionName] ?? definitionSchema, + DefinitionOverrides[definitionName] ?? + (OpenStringEnumDefinitions.has(definitionName) + ? openStringEnum(definitionSchema) + : definitionSchema), localDefinitionNames, file.namespace, exportNameByQualifiedName, diff --git a/packages/effect-codex-app-server/src/_generated/schema.gen.ts b/packages/effect-codex-app-server/src/_generated/schema.gen.ts index 90869b56aa89d..50fa95918b430 100644 --- a/packages/effect-codex-app-server/src/_generated/schema.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/schema.gen.ts @@ -17949,21 +17949,7 @@ export const PermissionsRequestApprovalResponse__FileSystemSpecialPath = Schema. ).annotate({ identifier: "PermissionsRequestApprovalResponse__FileSystemSpecialPath" }); export type ServerNotification__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -17975,23 +17961,7 @@ export type ServerNotification__CodexErrorInfo = }; export const ServerNotification__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -20035,21 +20005,7 @@ export const V2ConfigWriteResponse__ConfigLayerSource = Schema.Union( ).annotate({ identifier: "V2ConfigWriteResponse__ConfigLayerSource" }); export type V2ErrorNotification__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -20061,23 +20017,7 @@ export type V2ErrorNotification__CodexErrorInfo = }; export const V2ErrorNotification__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -22491,21 +22431,7 @@ export const V2RawResponseItemCompletedNotification__ConfigurationReasoning = Sc }); export type V2ReviewStartResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -22517,23 +22443,7 @@ export type V2ReviewStartResponse__CodexErrorInfo = }; export const V2ReviewStartResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -23029,21 +22939,7 @@ export const V2ThreadForkResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadForkResponse__ThreadStatus" }); export type V2ThreadForkResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -23055,23 +22951,7 @@ export type V2ThreadForkResponse__CodexErrorInfo = }; export const V2ThreadForkResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -23679,21 +23559,7 @@ export const V2ThreadListResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadListResponse__ThreadStatus" }); export type V2ThreadListResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -23705,23 +23571,7 @@ export type V2ThreadListResponse__CodexErrorInfo = }; export const V2ThreadListResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -24088,21 +23938,7 @@ export const V2ThreadMetadataUpdateResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadMetadataUpdateResponse__ThreadStatus" }); export type V2ThreadMetadataUpdateResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -24114,23 +23950,7 @@ export type V2ThreadMetadataUpdateResponse__CodexErrorInfo = }; export const V2ThreadMetadataUpdateResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -24502,21 +24322,7 @@ export const V2ThreadReadResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadReadResponse__ThreadStatus" }); export type V2ThreadReadResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -24528,23 +24334,7 @@ export type V2ThreadReadResponse__CodexErrorInfo = }; export const V2ThreadReadResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -25270,21 +25060,7 @@ export const V2ThreadResumeResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadResumeResponse__ThreadStatus" }); export type V2ThreadResumeResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -25296,23 +25072,7 @@ export type V2ThreadResumeResponse__CodexErrorInfo = }; export const V2ThreadResumeResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -25641,21 +25401,7 @@ export const V2ThreadRevertResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadRevertResponse__ThreadStatus" }); export type V2ThreadRevertResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -25667,23 +25413,7 @@ export type V2ThreadRevertResponse__CodexErrorInfo = }; export const V2ThreadRevertResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -26181,21 +25911,7 @@ export const V2ThreadStartedNotification__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadStartedNotification__ThreadStatus" }); export type V2ThreadStartedNotification__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -26207,23 +25923,7 @@ export type V2ThreadStartedNotification__CodexErrorInfo = }; export const V2ThreadStartedNotification__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -26686,21 +26386,7 @@ export const V2ThreadStartResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadStartResponse__ThreadStatus" }); export type V2ThreadStartResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -26712,23 +26398,7 @@ export type V2ThreadStartResponse__CodexErrorInfo = }; export const V2ThreadStartResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -27016,21 +26686,7 @@ export const V2ThreadTokenUsageUpdatedNotification__ThreadTokenUsage = Schema.St }).annotate({ identifier: "V2ThreadTokenUsageUpdatedNotification__ThreadTokenUsage" }); export type V2ThreadTurnsListResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -27042,23 +26698,7 @@ export type V2ThreadTurnsListResponse__CodexErrorInfo = }; export const V2ThreadTurnsListResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -27429,21 +27069,7 @@ export const V2ThreadUnarchiveResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadUnarchiveResponse__ThreadStatus" }); export type V2ThreadUnarchiveResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -27455,23 +27081,7 @@ export type V2ThreadUnarchiveResponse__CodexErrorInfo = }; export const V2ThreadUnarchiveResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -27760,21 +27370,7 @@ export const V2ThreadUnarchiveResponse__CollabAgentState = Schema.Struct({ }).annotate({ identifier: "V2ThreadUnarchiveResponse__CollabAgentState" }); export type V2TurnCompletedNotification__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -27786,23 +27382,7 @@ export type V2TurnCompletedNotification__CodexErrorInfo = }; export const V2TurnCompletedNotification__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -28101,21 +27681,7 @@ export const V2TurnPlanUpdatedNotification__TurnPlanStep = Schema.Struct({ }).annotate({ identifier: "V2TurnPlanUpdatedNotification__TurnPlanStep" }); export type V2TurnStartedNotification__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -28127,23 +27693,7 @@ export type V2TurnStartedNotification__CodexErrorInfo = }; export const V2TurnStartedNotification__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -28569,21 +28119,7 @@ export const V2TurnStartParams__SandboxPolicy = Schema.Union( ).annotate({ identifier: "V2TurnStartParams__SandboxPolicy" }); export type V2TurnStartResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -28595,23 +28131,7 @@ export type V2TurnStartResponse__CodexErrorInfo = }; export const V2TurnStartResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( diff --git a/packages/effect-codex-app-server/src/schema.ts b/packages/effect-codex-app-server/src/schema.ts index 2d5349752a00d..e9acee3bb9e91 100644 --- a/packages/effect-codex-app-server/src/schema.ts +++ b/packages/effect-codex-app-server/src/schema.ts @@ -1,3 +1,8 @@ export * from "./_generated/schema.gen.ts"; export * from "./_generated/meta.gen.ts"; export * from "./_generated/namespaces.gen.ts"; + +// Mirrors codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS, which the +// generated schema does not carry. Codex counts Unicode scalar values (Rust +// `chars()`), not UTF-16 code units. +export const MAX_THREAD_GOAL_OBJECTIVE_CHARS = 4_000; diff --git a/packages/provider-acp-registry/package.json b/packages/provider-acp-registry/package.json index 3af94a87eb5a0..ad00855cea2c4 100644 --- a/packages/provider-acp-registry/package.json +++ b/packages/provider-acp-registry/package.json @@ -29,7 +29,7 @@ } }, "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "effect-tsgo tsrs --noEmit", "test": "vp test run --config ../../vite.config.ts --dir ." }, "dependencies": { diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 01bfc9b4bc607..b486af3108e57 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -6,7 +6,7 @@ import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contrac import { readCustomModelEntries } from "@t3tools/shared/model"; import { AcpRegistrySettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -54,7 +54,7 @@ export interface AcpRegistryAdapterV2Options { ) => Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope >; readonly continuationRequests?: Parameters[0]["continuationRequests"]; readonly assertComplete?: Effect.Effect; @@ -82,7 +82,7 @@ function makeAcpRegistryRuntime( ): Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope > => Effect.gen(function* () { const { processEnvironment, ...runtimeInput } = input; @@ -164,7 +164,7 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu export type AcpRegistryAdapterV2DriverEnv = | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | SecureRandom.SecureRandom | FileSystem.FileSystem | AcpRegistrySupport.AcpRegistryCatalog | IdAllocator.IdAllocatorV2 diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index c30302aec5e95..314f16e50d9aa 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -13,7 +13,8 @@ import { AcpRegistrySettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -391,7 +392,10 @@ export const checkAcpRegistryProviderReadiness = Effect.fn( : snapshot; }); -export type AcpRegistryDriverEnv = AcpRegistryAdapterV2DriverEnv | ProviderHost.ProviderHost; +export type AcpRegistryDriverEnv = + | AcpRegistryAdapterV2DriverEnv + | Digest.Digest + | ProviderHost.ProviderHost; export const AcpRegistryDriver: ProviderDriver = { driverKind: DRIVER_KIND, @@ -422,7 +426,7 @@ export const AcpRegistryDriver: ProviderDriver, ): Effect.Effect => effect.pipe( Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), ); return { diff --git a/packages/provider-acp-registry/src/server/probe.ts b/packages/provider-acp-registry/src/server/probe.ts index 9a5904f0e2283..47f9b84e412f7 100644 --- a/packages/provider-acp-registry/src/server/probe.ts +++ b/packages/provider-acp-registry/src/server/probe.ts @@ -13,7 +13,7 @@ import { type ServerProviderSlashCommand, } from "@t3tools/contracts"; import { AcpRegistrySettings } from "../settings.ts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -317,11 +317,13 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo ): Effect.fn.Return< AcpRegistryConfigurationProbeResult, AcpRegistryOperationError, - AcpRegistrySupport.AcpRegistryCatalog | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + | AcpRegistrySupport.AcpRegistryCatalog + | ChildProcessSpawner.ChildProcessSpawner + | SecureRandom.SecureRandom > { const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const result = yield* Effect.gen(function* () { const resolved = yield* catalog @@ -351,7 +353,7 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo Layer.provide( Layer.mergeAll( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - Layer.succeed(Crypto.Crypto, crypto), + Layer.succeed(SecureRandom.SecureRandom, random), ), ), ), @@ -416,7 +418,7 @@ const makeAcpRegistryManagementRuntime = Effect.fn("AcpRegistryProbe.makeManagem function* (input: AcpRegistryManagementInput) { const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const resolved = yield* catalog .resolve(input.settings, input.cwd, input.environment) .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)); @@ -435,7 +437,7 @@ const makeAcpRegistryManagementRuntime = Effect.fn("AcpRegistryProbe.makeManagem Layer.provide( Layer.mergeAll( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - Layer.succeed(Crypto.Crypto, crypto), + Layer.succeed(SecureRandom.SecureRandom, random), ), ), ), diff --git a/packages/provider-acp/package.json b/packages/provider-acp/package.json index 28d63f605e8f9..5796d67c257ce 100644 --- a/packages/provider-acp/package.json +++ b/packages/provider-acp/package.json @@ -37,7 +37,7 @@ } }, "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "effect-tsgo tsrs --noEmit", "test": "vp test run --config ../../vite.config.ts --dir ." }, "dependencies": { diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index 0f7da7c836f14..4274459974123 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -6,7 +6,7 @@ import * as NodePath from "node:path"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -21,6 +21,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as EffectAcpClient from "effect-acp/client"; @@ -482,18 +483,18 @@ export function terminateLinuxCgroupLease( let lastRemovalFailure: { readonly cause: unknown } | null = null; for (let attempt = 0; attempt < 200; attempt += 1) { if (!lease.exists()) return; - const killed = yield* Effect.try(() => lease.kill()).pipe(Effect.result); + const killed = Result.try(() => lease.kill()); if (Result.isFailure(killed)) { - const cause = killed.failure.cause; + const cause = killed.failure; const code = (cause as NodeJS.ErrnoException | undefined)?.code; if (code !== "ENOENT") return yield* failure("kill", cause); if (!lease.exists()) return; yield* Effect.sleep("10 millis"); continue; } - const populated = yield* Effect.try(() => lease.populated()).pipe(Effect.result); + const populated = Result.try(() => lease.populated()); if (Result.isFailure(populated)) { - const cause = populated.failure.cause; + const cause = populated.failure; const code = (cause as NodeJS.ErrnoException | undefined)?.code; if (code === "ENOENT" && !lease.exists()) return; if (code === "ENOENT") { @@ -507,9 +508,9 @@ export function terminateLinuxCgroupLease( yield* Effect.sleep("10 millis"); continue; } - const removed = yield* Effect.try(() => lease.remove()).pipe(Effect.result); + const removed = Result.try(() => lease.remove()); if (Result.isFailure(removed)) { - const cause = removed.failure.cause; + const cause = removed.failure; const code = (cause as NodeJS.ErrnoException | undefined)?.code; if (code === "ENOENT" && !lease.exists()) return; if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "ENOENT") { @@ -1125,6 +1126,8 @@ export class AcpSessionRuntime extends Context.Service< readonly drainEvents: Effect.Effect; readonly getModeState: Effect.Effect; readonly getConfigOptions: Effect.Effect>; + /** The current configuration options, then each later change. */ + readonly configOptionChanges: Stream.Stream>; readonly loadSession: ( sessionId: string, options?: AcpSessionActivationOptions, @@ -1136,6 +1139,7 @@ export class AcpSessionRuntime extends Context.Service< readonly forkSession: ( sessionId: string, options?: AcpSessionActivationOptions, + meta?: NonNullable, ) => Effect.Effect; readonly listSessions: ( cursor?: string, @@ -1235,16 +1239,16 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( ): Effect.fn.Return< AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope > { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeScope = yield* Scope.fork(yield* Scope.Scope); const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); const shownToolCallIds = new Set(); - const assistantItemRuntimeId = yield* crypto.randomUUIDv4().pipe( + const assistantItemRuntimeId = yield* random.uuidV4().pipe( Effect.mapError( (cause) => new EffectAcpErrors.AcpTransportError({ @@ -1254,7 +1258,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( ), ); const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); - const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); + const configOptionsRef = yield* SubscriptionRef.make(sessionConfigOptionsFromSetup(undefined)); const initializeStateRef = yield* Ref.make({ _tag: "NotStarted" }); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); const startupMetadataRef = yield* Ref.make>( @@ -1681,17 +1685,26 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); + // Counts applied `config_option_update`s; see setConfigOption. + const configOptionUpdates = yield* Ref.make(0); const processSessionUpdate = (notification: EffectAcpSchema.SessionNotification) => - handleSessionUpdate({ - queue: eventQueue, - modeStateRef, - configOptionsRef, - toolCallsRef, - shownToolCallIds, - assistantSegmentRef, - assistantItemRuntimeId, - params: notification, - }); + (notification.update.sessionUpdate === "config_option_update" + ? Ref.update(configOptionUpdates, (count) => count + 1) + : Effect.void + ).pipe( + Effect.andThen( + handleSessionUpdate({ + queue: eventQueue, + modeStateRef, + configOptionsRef, + toolCallsRef, + shownToolCallIds, + assistantSegmentRef, + assistantItemRuntimeId, + params: notification, + }), + ), + ); yield* acp.handleSessionUpdate((notification) => notificationSemaphore.withPermit( @@ -1806,7 +1819,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( value: string | boolean, allowUnlistedValue: boolean, ): Effect.fn.Return { - const configOptions = yield* Ref.get(configOptionsRef); + const configOptions = yield* SubscriptionRef.get(configOptionsRef); const configOption = findSessionConfigOption(configOptions, configId); if (!configOption) { return; @@ -1867,7 +1880,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( | EffectAcpSchema.ResumeSessionResponse, ) { const configOptions = sessionConfigOptionsFromSetup(response); - yield* Ref.set(configOptionsRef, configOptions); + yield* SubscriptionRef.set(configOptionsRef, configOptions); yield* Queue.offer(eventQueue, { _tag: "ConfigOptionsUpdated", configOptions, @@ -1910,9 +1923,15 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( sessionSetupResult.configOptions !== undefined && sessionSetupResult.configOptions !== null ) { - yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + yield* SubscriptionRef.set( + configOptionsRef, + sessionConfigOptionsFromSetup(sessionSetupResult), + ); } else if (!syntheticReplayIdle) { - yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + yield* SubscriptionRef.set( + configOptionsRef, + sessionConfigOptionsFromSetup(sessionSetupResult), + ); } const nextState = { sessionId, @@ -2015,7 +2034,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( validateConfigOptionValue(configId, value, options?.allowUnlistedValue === true).pipe( Effect.andThen(getStartedState), Effect.flatMap((started) => - Ref.get(configOptionsRef).pipe( + SubscriptionRef.get(configOptionsRef).pipe( Effect.flatMap((configOptions) => { const existing = findSessionConfigOption(configOptions, configId); if (existing && configOptionCurrentValueMatches(existing, value)) { @@ -2036,11 +2055,27 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( configId, value: String(value), } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); - return runLoggedRequest( - "session/set_config_option", - requestPayload, - acp.agent.setSessionConfigOption(requestPayload), - ).pipe(Effect.tap((response) => updateConfigOptions(response))); + // An update the agent pushed while the write was in flight is + // newer than its result: Kiro 2.32 answers a `model` write + // without `model`, then advertises it, and that update can be + // handled before this fiber resumes. The result then stays out. + return Ref.get(configOptionUpdates).pipe( + Effect.flatMap((updatesBefore) => + runLoggedRequest( + "session/set_config_option", + requestPayload, + acp.agent.setSessionConfigOption(requestPayload), + ).pipe( + Effect.tap((response) => + Ref.get(configOptionUpdates).pipe( + Effect.flatMap((updatesNow) => + updatesNow === updatesBefore ? updateConfigOptions(response) : Effect.void, + ), + ), + ), + ), + ), + ); }), ), ), @@ -2244,7 +2279,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( ); yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); - yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + yield* SubscriptionRef.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); const nextState = { sessionId, @@ -2401,7 +2436,8 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( getEvents: () => Stream.fromQueue(eventQueue), drainEvents, getModeState: Ref.get(modeStateRef), - getConfigOptions: Ref.get(configOptionsRef), + getConfigOptions: SubscriptionRef.get(configOptionsRef), + configOptionChanges: SubscriptionRef.changes(configOptionsRef), loadSession: (sessionId, activationOptions) => start.pipe( Effect.flatMap((started) => { @@ -2432,7 +2468,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( }), Effect.flatMap((response) => adoptSession(sessionId, response)), ), - forkSession: (sessionId, activationOptions) => + forkSession: (sessionId, activationOptions, meta) => start.pipe( Effect.flatMap((started) => { const requestPayload = { @@ -2440,6 +2476,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( cwd: options.cwd, ...sessionSetupFields(started.initializeResult), mcpServers: sessionMcpServers(started.initializeResult, activationOptions), + ...(meta === undefined ? {} : { _meta: meta }), } satisfies EffectAcpSchema.ForkSessionRequest; return runLoggedRequest( "session/fork", @@ -2634,7 +2671,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( if (modeState?.currentModeId === modeId) { return {} satisfies EffectAcpSchema.SetSessionModeResponse; } - const modeConfigOption = (yield* Ref.get(configOptionsRef))?.find( + const modeConfigOption = (yield* SubscriptionRef.get(configOptionsRef))?.find( (option) => option.category === "mode" && option.type === "select", ); if (modeConfigOption === undefined && modeState !== undefined) { @@ -2685,7 +2722,7 @@ export const layer = ( ): Layer.Layer< AcpSessionRuntime, EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom > => Layer.effect(AcpSessionRuntime, make(options)); function sessionConfigOptionsFromSetup( @@ -2737,7 +2774,9 @@ const handleSessionUpdate = }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; - readonly configOptionsRef: Ref.Ref>; + readonly configOptionsRef: SubscriptionRef.SubscriptionRef< + ReadonlyArray + >; readonly toolCallsRef: Ref.Ref>; readonly shownToolCallIds: Set; readonly assistantSegmentRef: Ref.Ref; @@ -2746,7 +2785,7 @@ const handleSessionUpdate = }): Effect.fn.Return { if (params.update.sessionUpdate === "config_option_update") { const configOptions = params.update.configOptions; - yield* Ref.set(configOptionsRef, configOptions); + yield* SubscriptionRef.set(configOptionsRef, configOptions); yield* Ref.update( modeStateRef, (current) => parseSessionModeState({ configOptions }) ?? current, diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index b40824edf4c37..718e08ed36908 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -36,13 +36,18 @@ import { AgentScope, AgentScopeThreadId } from "@t3tools/shared/AgentScope"; import { HTML_RENDER_TOOL_NAME } from "@t3tools/shared/htmlRender"; import { modelSelectionsEqual } from "@t3tools/shared/model"; import { resolveT3McpToolId } from "@t3tools/shared/t3McpToolPresentation"; -import { compactDynamicToolOutput, htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; +import { + compactDynamicToolOutput, + htmlRenderFromToolItem, + mcpToolResultOutput, +} from "@t3tools/shared/toolOutput"; import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; import * as Cause from "effect/Cause"; -import * as Crypto from "effect/Crypto"; +import * as Clock from "effect/Clock"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; -import type * as Duration from "effect/Duration"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; @@ -142,6 +147,24 @@ const ACP_DEFERRED_FINALIZE_DEBOUNCE: Duration.Input = "3000 millis"; */ const ACP_TEXT_FLUSH_INTERVAL: Duration.Input = "50 millis"; +// Output of the running prompt. Session metadata and unknown updates do not reset its timeout. +const ACP_PROMPT_ACTIVITY_UPDATES: ReadonlySet = new Set([ + "agent_message", + "agent_message_chunk", + "agent_thought", + "agent_thought_chunk", + "tool_call", + "tool_call_update", + "tool_call_content_chunk", + "terminal_output_chunk", + "terminal_update", + "plan", + "plan_update", + "plan_removed", + "compaction_update", + "compaction_summary_chunk", +]); + export interface AcpAdapterV2RuntimeInput { readonly cwd: string; readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; @@ -159,6 +182,7 @@ export interface AcpAdapterV2RuntimeInput { readonly onTermination: NonNullable; readonly onOutgoingResponseFailure?: AcpSessionRuntime.AcpSessionRuntimeOptions["onOutgoingResponseFailure"]; readonly onOutgoingResponse?: AcpSessionRuntime.AcpSessionRuntimeOptions["onOutgoingResponse"]; + readonly onIncomingRequest?: AcpSessionRuntime.AcpSessionRuntimeOptions["onIncomingRequest"]; } export type AcpAdapterV2NativeLogging = Pick< @@ -309,6 +333,12 @@ export interface AcpAdapterV2Flavor { readonly normalizeSessionUpdate?: ( notification: EffectAcpSchema.SessionNotification, ) => EffectAcpSchema.SessionNotification; + /** + * Agent updates that belong to no turn (Kiro's own cloud-config fetch). They + * are dropped before anything reads them, so they neither show in the turn + * nor count as it starting. + */ + readonly ignoreSessionUpdate?: (notification: EffectAcpSchema.SessionNotification) => boolean; readonly onAvailableCommandsUpdate?: ( commands: ReadonlyArray, ) => Effect.Effect; @@ -336,10 +366,16 @@ export interface AcpAdapterV2Flavor { ) => Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope >; readonly customModels?: ReadonlyArray; readonly resolveModelId?: (selection: ModelSelection) => string | undefined; + /** + * The agent advertises its model option only after `session/new` returns + * (Kiro sends it in a `config_option_update`), so in-session model switching + * is not inferred from the setup result. + */ + readonly modelOptionArrivesLate?: boolean; readonly getModelContextWindow?: ( selection: ModelSelection, session: AcpSessionRuntime.AcpSessionRuntimeStartResult, @@ -349,9 +385,23 @@ export interface AcpAdapterV2Flavor { readonly startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult; readonly modelSelection: ModelSelection; }) => Effect.Effect; + /** + * Model-selection option ids `applyModelSelection` sets itself under a + * native id, which the generic option loop then skips (Kiro's + * `reasoningEffort` is its `effortLevel`). + */ + readonly ownedModelOptionIds?: ReadonlyArray; readonly sessionModeForPolicy?: ( policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, ) => string | undefined; + /** + * Native select-option values to apply for a runtime policy (e.g. Kiro's + * `autopilot`), set on every session configure. A value the session does + * not advertise is skipped; an agent rejecting it fails the session. + */ + readonly sessionConfigForPolicy?: ( + policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + ) => ReadonlyArray<{ readonly id: string; readonly value: string }>; readonly clientFileSystem?: { readonly readTextFile: ( request: EffectAcpSchema.ReadTextFileRequest, @@ -383,6 +433,41 @@ export interface AcpAdapterV2Flavor { ) => Effect.Effect; readonly subagentsIdleOnTurnCompletion?: boolean; readonly supportsCompaction?: boolean; + /** + * Native steering: `send` queues a message into the running turn and returns the + * agent's id for it, which `injectedSteerId` reads from the update that delivers it. + */ + readonly steering?: { + readonly send: ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + sessionId: string, + text: string, + ) => Effect.Effect; + readonly injectedSteerId: ( + notification: EffectAcpSchema.SessionNotification, + ) => string | undefined; + }; + /** + * Native fork from an earlier turn. `nativeTurnId` reads the agent's id for a running + * turn's prompt, which the turn keeps as its native ref; forking from that turn sends + * `forkMeta(id)` as the `session/fork` `_meta`. + */ + readonly forkFromTurn?: { + readonly supported: ( + initializeResult: AcpSessionRuntime.AcpSessionRuntimeStartResult["initializeResult"], + ) => boolean; + readonly nativeTurnId: ( + notification: EffectAcpSchema.SessionNotification, + ) => string | undefined; + readonly forkMeta: ( + nativeTurnId: string, + ) => NonNullable; + }; + /** Compacts with the agent's own request, for agents that answer a `/compact` prompt as chat. */ + readonly compactSession?: ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + sessionId: string, + ) => Effect.Effect; readonly runtimeHarness?: string; readonly registerExtensions?: ( context: AcpAdapterV2ExtensionContext, @@ -423,6 +508,15 @@ export interface AcpAdapterV2Flavor { readonly assertComplete?: Effect.Effect; /** Interrupt the local prompt fiber before `session/cancel` (Grok wedged prompts). */ readonly interruptPromptOnCancel?: boolean; + /** + * Hold `session/cancel` until an update after `session/prompt` matches `isStart`, + * or `bound` has passed; skip it if the prompt settles first. Kiro 2.27.1 can + * drop a cancel sent just after the prompt and run the turn to completion. + */ + readonly cancelAfterPromptStarts?: { + readonly isStart: (update: EffectAcpSchema.SessionUpdate) => boolean; + readonly bound: Duration.Input; + }; /** * The agent's PromptResponse.usage counters read per-turn. The ACP spec * leaves usage semantics open, so this stays unset until proven live. @@ -430,6 +524,11 @@ export interface AcpAdapterV2Flavor { readonly reportsPerTurnPromptUsage?: boolean; /** Gemini-style usage: inputTokens leaves out cachedReadTokens, outputTokens leaves out thoughtTokens. */ readonly geminiPromptUsage?: boolean; + /** + * Fails a dispatched prompt after this long with no update for its session while no + * agent request, tool or subagent is open, and replaces the agent before the next turn. + */ + readonly promptInactivityTimeout?: Duration.Input; /** Grok can retain task_already_running until process exit. */ readonly restartRuntimeAfterInterrupt?: boolean; /** Prompt failures after which the agent process keeps failing until replaced. */ @@ -525,6 +624,8 @@ export interface AcpAdapterV2Options { }; readonly testHooks?: { readonly onDeferredFinalizeScheduled?: (debounce: Duration.Input) => Effect.Effect; + /** `cancelAfterPromptStarts`: Stop is holding `session/cancel` until the prompt starts. */ + readonly onCancelHeld?: () => Effect.Effect; readonly afterPromptWireSettled?: () => Effect.Effect; readonly afterPromptSettledWithBackgroundWork?: () => Effect.Effect; readonly onNativeResponseLifecycle?: (event: { @@ -639,15 +740,57 @@ export const AcpProviderCapabilitiesV2 = { }, } satisfies OrchestrationV2ProviderCapabilities; +const STEER_DELIVERY_BOUND = "2 seconds"; + +/** + * `session/fork` `_meta` for a fork from `providerTurnId`: none for the session head, + * the flavor's meta for a turn the agent named, or `null` when the agent can't fork there. + */ +function forkFromTurnMetaFor(input: { + readonly driver: ProviderDriverKind; + readonly flavor: Pick; + readonly canForkFromTurn: boolean; + readonly forkInput: ProviderAdapter.ProviderAdapterV2ForkThreadInput; +}): NonNullable | undefined | null { + const { forkInput } = input; + if (forkInput.providerTurnId === undefined) return undefined; + const turns = (forkInput.sourceProviderTurns ?? []).filter( + (turn) => turn.providerThreadId === forkInput.sourceProviderThread.id, + ); + const target = turns.find((turn) => turn.id === forkInput.providerTurnId); + if (target === undefined) return null; + if (turns.every((turn) => turn.ordinal <= target.ordinal)) return undefined; + const ref = target.nativeTurnRef; + if ( + input.flavor.forkFromTurn === undefined || + !input.canForkFromTurn || + ref === null || + ref.driver !== input.driver || + ref.strength !== "strong" || + ref.nativeId === null + ) { + return null; + } + return input.flavor.forkFromTurn.forkMeta(ref.nativeId); +} + +function isCompactionMessage(message: ProviderAdapter.ProviderAdapterV2TurnMessage): boolean { + return message.text.trim() === "/compact" && message.attachments.length === 0; +} + function negotiatedCapabilities( base: OrchestrationV2ProviderCapabilities, started: AcpSessionRuntime.AcpSessionRuntimeStartResult, + modelOptionArrivesLate: boolean, + forksFromTurn: boolean, ): OrchestrationV2ProviderCapabilities { const agent = started.initializeResult.agentCapabilities ?? {}; const session = agent.sessionCapabilities; const setup = started.sessionSetupResult; const canSwitchModel = - findModelConfigOption(setup.configOptions) !== undefined || setup.models != null; + modelOptionArrivesLate || + findModelConfigOption(setup.configOptions) !== undefined || + setup.models != null; const canLoad = agent.loadSession === true; const canFork = session?.fork != null; return { @@ -660,7 +803,7 @@ function negotiatedCapabilities( ...base.threads, canReadThreadSnapshot: canLoad, canForkThread: canFork, - canForkFromTurn: false, + canForkFromTurn: canFork && forksFromTurn, }, tools: { ...base.tools, @@ -761,8 +904,7 @@ function acpMcpToolCallOutput(rawOutput: unknown): unknown { if (record === undefined) return rawOutput; if (!("result" in record) && !("error" in record)) return rawOutput; const result = unknownRecord(record.result); - const resultOutput = - result === undefined ? undefined : (result.structuredContent ?? result.content ?? undefined); + const resultOutput = result === undefined ? undefined : mcpToolResultOutput(result); const errorMessage = unknownRecord(record.error)?.message; if (typeof errorMessage !== "string") { return resultOutput ?? rawOutput; @@ -933,7 +1075,7 @@ export function acpToolCallDiffPatch(content: unknown): string | undefined { const diff = unknownRecord(entry); return diff?.type === "diff" ? [diff] : []; }); - for (const diff of diffs) { + const patches = diffs.flatMap((diff): ReadonlyArray<{ text: string; path?: string }> => { const patch = unknownRecord(diff.patch); if (typeof patch?.text === "string") { const change = @@ -949,15 +1091,17 @@ export function acpToolCallDiffPatch(content: unknown): string | undefined { const before = change.operation === "add" ? "/dev/null" : JSON.stringify(`a/${oldPath}`); const after = change.operation === "delete" ? "/dev/null" : JSON.stringify(`b/${change.path}`); - return `diff --git ${JSON.stringify(`a/${oldPath}`)} ${JSON.stringify(`b/${change.path}`)}\n--- ${before}\n+++ ${after}\n${patch.text.trimStart()}`; + return [ + { + text: `diff --git ${JSON.stringify(`a/${oldPath}`)} ${JSON.stringify(`b/${change.path}`)}\n--- ${before}\n+++ ${after}\n${patch.text.trimStart()}`, + }, + ]; } - return patch.text; + return [{ text: patch.text }]; } - } - const v1Patches = diffs.flatMap((diff) => { if (typeof diff.path !== "string" || typeof diff.newText !== "string") return []; const oldText = typeof diff.oldText === "string" ? diff.oldText : undefined; - const patch = structuredPatch( + const structured = structuredPatch( oldText === undefined ? "/dev/null" : diff.path, diff.path, oldText ?? "", @@ -969,9 +1113,21 @@ export function acpToolCallDiffPatch(content: unknown): string | undefined { maxEditLength: oldText && diff.newText ? ACP_V1_DIFF_MAX_EDITS : Number.POSITIVE_INFINITY, }, ); - return patch === undefined || patch.hunks.length === 0 ? [] : [patch]; + return structured === undefined || structured.hunks.length === 0 + ? [] + : [{ text: formatPatch(structured, FILE_HEADERS_ONLY), path: diff.path }]; }); - return v1Patches.length === 0 ? undefined : formatPatch(v1Patches, FILE_HEADERS_ONLY); + // Diff parsers split at Git headers once one exists, so every section then needs its own. + const hasGitHeader = patches.some(({ text }) => /^diff --git /m.test(text)); + return patches.length === 0 + ? undefined + : patches + .map(({ text, path }) => + hasGitHeader && path !== undefined + ? `diff --git ${JSON.stringify(`a/${path}`)} ${JSON.stringify(`b/${path}`)}\n${text}` + : text, + ) + .join("\n"); } function pathFromToolCall(toolCall: AcpToolCallState): string | undefined { @@ -1064,6 +1220,18 @@ function toolStatus( type ProjectedToolStatus = ReturnType | "interrupted"; +/** + * Some agents answer Stop's `session/cancel` by failing the tools and subagents + * that were running (Kiro: "Tool execution was cancelled by the user."). After + * Stop, a failure is shown as the interruption it was. + */ +function stoppedToolStatus( + turn: Pick, + tool: AcpToolCallState, +): "interrupted" | undefined { + return turn.interrupted && tool.status === "failed" ? "interrupted" : undefined; +} + function nodeStatus(status: ProjectedToolStatus): OrchestrationV2ExecutionNode["status"] { return status === "pending" ? "running" : status; } @@ -1082,13 +1250,13 @@ function selectPermissionOptionId( request: EffectAcpSchema.RequestPermissionRequest, decision: Exclude, ): string | undefined { - const kind = - decision === "acceptForSession" - ? "allow_always" - : decision === "accept" - ? "allow_once" - : "reject_once"; - return request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined; + const optionIdOfKind = (kind: EffectAcpSchema.PermissionOption["kind"]) => + request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined; + if (decision === "acceptForSession") { + // Agents may offer only allow_once, and answering "cancelled" would stop the turn the user approved. + return optionIdOfKind("allow_always") ?? optionIdOfKind("allow_once"); + } + return optionIdOfKind(decision === "accept" ? "allow_once" : "reject_once"); } // Prefer allow-once; allow-always can persist beyond the session. @@ -1194,8 +1362,19 @@ interface ActiveAcpTurn { promptSettledStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; workAccepted: boolean; readonly promptWireSettled: Deferred.Deferred; + /** Completed once `session/prompt` is the runtime's active prompt. */ + readonly promptDispatched: Deferred.Deferred; + /** Clock millis just before `session/prompt` was handed to the runtime. */ + promptSentAt: number | null; + /** Completed when an update after dispatch matches `cancelAfterPromptStarts.isStart`. */ + readonly promptStarted: Deferred.Deferred; promptUsage: EffectAcpSchema.Usage | undefined; backgroundFinalizeGeneration: number; + /** The agent's own id for this turn's prompt, from `forkFromTurn.nativeTurnId`. */ + nativePromptId: string | undefined; + /** Clock millis of the prompt's last progress, for `promptInactivityTimeout`. */ + lastProgressAt: number; + inactivityWatchdog: Fiber.Fiber | undefined; } type AcpRuntimeTeardownState = @@ -1459,7 +1638,7 @@ function shouldPersistToolUpdate( export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( options: AcpAdapterV2Options, ) { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const fileSystem = yield* FileSystem.FileSystem; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const agentScope = yield* AgentScope; @@ -1659,6 +1838,15 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( input.runtimePolicy; const events = yield* Queue.unbounded(); const activeTurn = yield* Ref.make(null); + // Keyed by the agent's steer id; an injection can arrive before its send returns. + const steerInjections = new Map>(); + const steerInjection = (steerId: string) => { + const existing = steerInjections.get(steerId); + if (existing !== undefined) return existing; + const created = Deferred.makeUnsafe(); + steerInjections.set(steerId, created); + return created; + }; let pendingStopCloseTurn: ActiveAcpTurn | null = null; let followsAgentWorkState = false; let agentWork: Extract< @@ -2049,6 +2237,40 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.void; const nativeLogging = options.nativeLogging?.(input.threadId); + const promptInactivityTimeoutMillis = + flavor.promptInactivityTimeout === undefined + ? undefined + : Duration.toMillis(flavor.promptInactivityTimeout); + // Each runtime owns its unanswered agent requests, so a dead process's leftovers + // and late callbacks never reach its replacement. + let openAgentRequests = new Set(); + const notePromptProgress = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (notification?: EffectAcpSchema.SessionNotification) { + if (promptInactivityTimeoutMillis === undefined) return; + if ( + notification !== undefined && + !ACP_PROMPT_ACTIVITY_UPDATES.has(notification.update.sessionUpdate) + ) { + return; + } + const context = yield* Ref.get(activeTurn); + if ( + context === null || + (notification !== undefined && + notification.sessionId !== context.nativeThreadId && + !context.subagentsBySessionId.has(notification.sessionId)) + ) { + return; + } + context.lastProgressAt = yield* Clock.currentTimeMillis; + }); + const closeAgentRequest = (agentRequests: Set, requestId: string) => + Effect.suspend(() => + agentRequests.delete(requestId) && agentRequests === openAgentRequests + ? notePromptProgress() + : Effect.void, + ); const handleRuntimeTerminationAtGeneration = (runtimeGeneration: number) => runRuntimeCallbackAtGeneration( runtimeGeneration, @@ -2085,6 +2307,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ).pipe(Effect.asVoid); const makeRuntimeInput = ( runtimeGeneration: number, + agentRequests: Set, mcpContext: AcpMcpContext, resumeSessionId?: string, onTermination: AcpAdapterV2RuntimeInput["onTermination"] = () => @@ -2110,7 +2333,16 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ...(flavor.clientCapabilitiesMeta ? { _meta: flavor.clientCapabilitiesMeta } : {}), }, clientInfo: { name: "t3-code", version: "0.0.0" }, - onTermination, + onTermination: (error) => + Effect.sync(() => agentRequests.clear()).pipe(Effect.andThen(onTermination(error))), + ...(promptInactivityTimeoutMillis === undefined + ? {} + : { + onIncomingRequest: (requestId: string) => + Effect.sync(() => { + agentRequests.add(requestId); + }), + }), onOutgoingResponseFailure: (requestId, error) => Ref.modify(nativeResponseAcknowledgements, (current) => { const entry = current.get(requestId); @@ -2139,7 +2371,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ), updated, ] as const; - }).pipe(Effect.flatten), + }).pipe(Effect.flatten, Effect.andThen(closeAgentRequest(agentRequests, requestId))), onOutgoingResponse: (requestId) => Ref.modify(nativeResponseAcknowledgements, (current) => { const entry = current.get(requestId); @@ -2168,13 +2400,13 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ), updated, ] as const; - }).pipe(Effect.flatten), + }).pipe(Effect.flatten, Effect.andThen(closeAgentRequest(agentRequests, requestId))), ...(nativeLogging?.requestLogger === undefined ? {} : { requestLogger: nativeLogging.requestLogger }), protocolLogging: nativeLogging?.protocolLogging ?? { - logIncoming: true, - logOutgoing: true, + logIncoming: false, + logOutgoing: false, logger: () => Effect.void, }, }; @@ -2808,9 +3040,14 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const emitSubagent = Effect.fnUntraced(function* ( context: ActiveAcpTurn, - update: AcpAdapterV2SubagentUpdate, + reported: AcpAdapterV2SubagentUpdate, ) { - // Hydration uses a new toolCallId but references the original child by task id. + // See stoppedToolStatus: after Stop, a failed subagent was interrupted. + const update: AcpAdapterV2SubagentUpdate = + context.interrupted && reported.status === "failed" + ? { ...reported, status: "interrupted" } + : reported; + // Hydration uses a new toolCallId but references the original child by subagent_id or task id. const existing = context.subagents.get(update.nativeTaskId) ?? (update.childSessionId !== null @@ -3306,10 +3543,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { for (const event of parseSessionUpdateEvent(notification).events) { if (event._tag === "ToolCallUpdated") { + const tool = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; yield* projectSubagentTool( subagent, notification.sessionId, - flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall, + tool, + stoppedToolStatus(owner.owner, tool), ); } } @@ -3675,7 +3914,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* rearmDeferredFinalize(context); return; } - const status = projectedStatus ?? toolStatus(toolCall.status); + const status = + projectedStatus ?? stoppedToolStatus(context, toolCall) ?? toolStatus(toolCall.status); const now = yield* DateTime.now; const nativeItemId = `${context.nativeThreadId}:tool:${toolCall.toolCallId}`; const ordinal = yield* resolveItemOrdinal(context, nativeItemId); @@ -4962,7 +5202,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } continue; } - yield* projectSubagentTool(subagent, notification.sessionId, toolCall); + yield* projectSubagentTool( + subagent, + notification.sessionId, + toolCall, + stoppedToolStatus(context, toolCall), + ); } return; } @@ -5927,10 +6172,38 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } }); + // Only the flavor's own start marker counts: agents also send late + // updates for the previous prompt (Kiro's context usage) and echoes + // that arrive before they would honor a cancel. + const markPromptStarted = Effect.fnUntraced(function* ( + notification: EffectAcpSchema.SessionNotification, + ) { + const promptStarts = flavor.cancelAfterPromptStarts?.isStart; + if (promptStarts === undefined) return; + const context = yield* Ref.get(activeTurn); + if (context === null || context.nativeThreadId !== notification.sessionId) return; + if (!(yield* Deferred.isDone(context.promptDispatched))) return; + if (!promptStarts(notification.update)) return; + yield* Deferred.succeed(context.promptStarted, undefined); + }); const projectAcpRuntimeSessionUpdateEffect = /* Provider events include streaming deltas; avoid a span for each update. */ Effect.fnUntraced( function* (rawNotification: EffectAcpSchema.SessionNotification) { + if (flavor.ignoreSessionUpdate?.(rawNotification) === true) return; + yield* notePromptProgress(rawNotification); + const nativePromptId = flavor.forkFromTurn?.nativeTurnId(rawNotification); + if (nativePromptId !== undefined) { + const context = yield* Ref.get(activeTurn); + if (context !== null && context.nativeThreadId === rawNotification.sessionId) { + context.nativePromptId ??= nativePromptId; + } + } + const injectedSteerId = flavor.steering?.injectedSteerId(rawNotification); + if (injectedSteerId !== undefined) { + yield* Deferred.succeed(steerInjection(injectedSteerId), undefined); + } + yield* markPromptStarted(rawNotification); const embedded = embeddedTerminalIdsFromSessionUpdate(rawNotification); if (embedded !== undefined) { rememberEmbeddedTerminals({ @@ -6066,6 +6339,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( readonly sessionUpdates?: boolean; readonly terminals?: boolean; readonly mcp?: boolean; + readonly scope?: Scope.Scope; } = {}, ) { yield* targetRuntime.getEvents().pipe( @@ -6078,7 +6352,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( flavor.onSessionEvent?.(event) ?? Effect.void, ).pipe(Effect.asVoid); }), - Effect.forkIn(runtimeScope ?? sessionScope), + Effect.forkIn(handlerOptions.scope ?? runtimeScope ?? sessionScope), ); const requestUserInput = ( request: AcpAdapterV2UserInputRequest, @@ -6147,8 +6421,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* userInput.acknowledgeNativeResponse; return response ?? ({ outcome: { outcome: "cancelled" } } as const); } - const dispositionFor = (policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy) => - (flavor.permissionDisposition ?? acpPermissionDisposition)(policy, params); + const dispositionFor = ( + policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + request: EffectAcpSchema.RequestPermissionRequest = params, + ) => (flavor.permissionDisposition ?? acpPermissionDisposition)(policy, request); if (followsAgentWorkState && dispositionFor(latestRuntimePolicy) === "ask") { if (!(yield* awaitRequestTurn(handlerGeneration))) return { outcome: { outcome: "cancelled" } } as const; @@ -6159,8 +6435,16 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const context = followsAgentWorkState ? yield* Ref.get(activeTurn) : yield* activeContext; + // Kiro sends the tool's kind on its `tool_call` and leaves it + // off the permission request for that call; take it from the + // tool already seen under the same id so policy can tell a + // read from a write. + const knownKind = context?.tools.get(params.toolCall.toolCallId)?.kind; const disposition = dispositionFor( context?.input.runtimePolicy ?? latestRuntimePolicy, + params.toolCall.kind == null && knownKind !== undefined + ? { ...params, toolCall: { ...params.toolCall, kind: knownKind } } + : params, ); if (disposition === "allow") { const optionId = selectAutoApprovedPermissionOption(params); @@ -6222,7 +6506,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( transportRequestId: pendingTransportRequestId, } = admitted.value.pending; const parsedPermission = parsePermissionRequest(params); - const decision = yield* Deferred.await(pendingDecision).pipe( + const answered = yield* Deferred.await(pendingDecision).pipe( Effect.ensuring( runRuntimeCallbackAtGeneration( handlerGeneration, @@ -6234,6 +6518,16 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ).pipe(Effect.asVoid), ), ); + // A card that never offered "this session" (Kiro's, whose + // allow_always saves a workspace rule) cannot be answered with + // it by a stale client or a hand-made respond call. + const offeredOptions = flavor.approvalOptions?.(params); + const decision = + answered === "acceptForSession" && + offeredOptions !== undefined && + !offeredOptions.some((option) => option.decision === "acceptForSession") + ? "accept" + : answered; if ( parsedPermission.kind !== "unknown" && (decision === "accept" || decision === "acceptForSession") @@ -6491,7 +6785,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const mcpBridge = yield* makeAcpMcpOverAcpBridge({ endpoint: mcpContext.endpoint, authorization: mcpContext.authorization, - allocateConnectionId: crypto.randomUUIDv4().pipe(Effect.orDie), + allocateConnectionId: random.uuidV4(), }); yield* Scope.addFinalizer(scope, mcpBridge.dispose); return mcpBridge; @@ -6508,11 +6802,14 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const mcpContext = yield* readMcpContext(threadId); runtimeMcpBridge = yield* makeRuntimeMcpBridge(mcpContext, runtimeScope); const runtimeGeneration = yield* Ref.get(runtimeCallbackGeneration); + openAgentRequests = new Set(); runtime = yield* flavor - .makeRuntime(makeRuntimeInput(runtimeGeneration, mcpContext, resumeSessionId)) + .makeRuntime( + makeRuntimeInput(runtimeGeneration, openAgentRequests, mcpContext, resumeSessionId), + ) .pipe( Effect.provideService(Scope.Scope, runtimeScope), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.provideService(AgentScope, agentScope), Effect.provideService(AgentScopeThreadId, threadId ?? undefined), @@ -6549,6 +6846,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( // Keep live callbacks active until replacement startup succeeds. const replacementGeneration = yield* allocateRuntimeCallbackGeneration; const replacementScope = yield* Scope.make(); + const replacementAgentRequests = new Set(); type CandidateLifecycle = | { readonly _tag: "Starting" } | { readonly _tag: "Terminated"; readonly error: EffectAcpErrors.AcpError } @@ -6584,6 +6882,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( .makeRuntime( makeRuntimeInput( replacementGeneration, + replacementAgentRequests, mcpContext, undefined, handleCandidateTermination, @@ -6591,7 +6890,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ) .pipe( Effect.provideService(Scope.Scope, replacementScope), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.provideService(AgentScope, agentScope), Effect.provideService(AgentScopeThreadId, threadId ?? undefined), @@ -6626,7 +6925,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* wireAcpRuntimeHandlers( replacementExit.value.replacementRuntime, replacementGeneration, - { sessionUpdates: false, terminals: false, mcp: false }, + { + sessionUpdates: false, + terminals: false, + mcp: false, + scope: replacementScope, + }, ); const lifecycle = yield* Ref.modify( candidateLifecycle, @@ -6642,6 +6946,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( runtime = replacementExit.value.replacementRuntime; runtimeScope = replacementScope; runtimeMcpBridge = replacementMcpBridge; + openAgentRequests = replacementAgentRequests; yield* Ref.set(runtimeCallbackGeneration, replacementGeneration); prepareTerminalEnvironment( yield* readMcpContext(threadId), @@ -6766,7 +7071,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* Ref.set(activeSessionId, started.sessionId); yield* setActiveSessionSetup(started); rememberTerminalEnvironment(started.sessionId, yield* readMcpContext(input.threadId)); - const capabilities = negotiatedCapabilities(flavor.capabilities, started); + const capabilities = negotiatedCapabilities( + flavor.capabilities, + started, + flavor.modelOptionArrivesLate === true, + flavor.forkFromTurn?.supported(started.initializeResult) === true, + ); const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true; const canResumeSession = started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null; @@ -6902,9 +7212,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }; }); } - const optionSelections = (modelSelection.options ?? []).map((selection) => - selection.value === ACP_EMPTY_OPTION_VALUE ? { ...selection, value: "" } : selection, - ); + const optionSelections = (modelSelection.options ?? []).flatMap((selection) => { + if (flavor.ownedModelOptionIds?.includes(selection.id) === true) return []; + return [ + selection.value === ACP_EMPTY_OPTION_VALUE ? { ...selection, value: "" } : selection, + ]; + }); const configOptions = yield* runtime.getConfigOptions; const availableConfigIds = new Set(configOptions.map((option) => option.id)); const hasNativeConfigWithSyntheticModeId = availableConfigIds.has( @@ -6955,6 +7268,17 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }), ); } + for (const selection of flavor.sessionConfigForPolicy?.(runtimePolicy) ?? []) { + const option = (yield* runtime.getConfigOptions).find( + (candidate) => candidate.id === selection.id, + ); + if (option?.type !== "select" || option.currentValue === selection.value) continue; + const advertisedValues = option.options.flatMap((entry) => + "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), + ); + if (!advertisedValues.includes(selection.value)) continue; + yield* runtime.setConfigOption(selection.id, selection.value); + } const policyMode = flavor.sessionModeForPolicy?.(runtimePolicy); if (policyMode !== undefined) { yield* runtime.setMode(policyMode); @@ -7070,11 +7394,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( providerThreadId: context.input.providerThread.id, nodeId: context.input.rootNodeId, runAttemptId: context.input.attemptId, - nativeTurnRef: { - driver, - nativeId: context.nativeTurnId, - strength: "weak", - }, + nativeTurnRef: + context.nativePromptId === undefined + ? { driver, nativeId: context.nativeTurnId, strength: "weak" } + : { driver, nativeId: context.nativePromptId, strength: "strong" }, ordinal: context.input.providerTurnOrdinal, status, startedAt: context.startedAt, @@ -7133,6 +7456,30 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } }); + /** + * `cancelAfterPromptStarts`: waits until the agent started the prompt or the + * bound passed since `session/prompt` (since Stop, if none was sent). False + * when the prompt settled first, so there is nothing to cancel. + */ + const promptStillRunsAfterHold = Effect.fnUntraced(function* (context: ActiveAcpTurn) { + const hold = flavor.cancelAfterPromptStarts; + if (hold === undefined) return true; + if (yield* Deferred.isDone(context.promptStarted)) return true; + yield* options.testHooks?.onCancelHeld?.() ?? Effect.void; + const boundPassed = Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const elapsed = now - (context.promptSentAt ?? now); + yield* Effect.sleep( + Duration.millis(Math.max(0, Duration.toMillis(hold.bound) - elapsed)), + ); + }); + return yield* Effect.raceFirst( + Effect.raceFirst(Deferred.await(context.promptStarted), boundPassed).pipe( + Effect.as(true), + ), + Deferred.await(context.completed).pipe(Effect.as(false)), + ); + }); const quarantineStoppedRun = Effect.fnUntraced(function* () { yield* continuationPermit.withPermit( Effect.gen(function* () { @@ -7159,6 +7506,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const settledStatus = context.interrupted ? "interrupted" : status; context.finalizedStatus = settledStatus; context.finalized = true; + const watchdog = context.inactivityWatchdog; + context.inactivityWatchdog = undefined; + if (watchdog !== undefined) { + yield* Fiber.interrupt(watchdog).pipe(Effect.forkDetach, Effect.asVoid); + } const directStopQuarantine = yield* Ref.get(stoppedRunQuarantine); if (flavor.subagentsIdleOnTurnCompletion === true) { for (const subagent of context.subagents.values()) { @@ -7187,9 +7539,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } const now = yield* DateTime.now; if ( - flavor.supportsCompaction === true && - context.input.message.text.trim() === "/compact" && - context.input.message.attachments.length === 0 && + (flavor.supportsCompaction === true || flavor.compactSession !== undefined) && + isCompactionMessage(context.input.message) && settledStatus === "completed" ) { const nativeItemId = `${context.nativeTurnId}:compaction`; @@ -7307,6 +7658,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } yield* Ref.set(activeTurn, null); + steerInjections.clear(); if ( postSettleContinuationEnabled && settledStatus === "completed" && @@ -7354,6 +7706,97 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ); }); + const promptInactivityPaused = Effect.fn("AcpAdapterV2.promptInactivityPaused")(function* ( + context: ActiveAcpTurn, + ) { + if (openAgentRequests.size > 0) return true; + if ((yield* Ref.get(pendingRuntimeRequests)).size > 0) return true; + for (const tool of context.tools.values()) { + const status = toolStatus(tool.status); + if (status === "pending" || status === "running" || status === "waiting") return true; + } + for (const subagent of context.subagents.values()) { + if (acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) return true; + } + return false; + }); + + /** + * Holds the transition permit from the deadline check through process teardown, + * so Stop and the next turn see either a running prompt or a replaced runtime. + */ + const retireInactivePrompt = Effect.fn("AcpAdapterV2.retireInactivePrompt")(function* ( + context: ActiveAcpTurn, + generation: number, + timeoutMillis: number, + ) { + const retired = yield* runRuntimeCallbackAtGeneration( + generation, + Effect.gen(function* () { + if (context.finalized || context.interrupted) return "done" as const; + if ((yield* Ref.get(activeTurn)) !== context) return "done" as const; + if (yield* Deferred.isDone(context.promptWireSettled)) return "done" as const; + const now = yield* Clock.currentTimeMillis; + if (context.lastProgressAt + timeoutMillis > now) return "rearm" as const; + if (yield* promptInactivityPaused(context)) { + context.lastProgressAt = now; + return "rearm" as const; + } + context.inactivityWatchdog = undefined; + openAgentRequests.clear(); + yield* quarantineNativeTransportAtGeneration(generation); + yield* advanceRuntimeCallbackGeneration; + yield* Ref.set(runtimeRestartRequired, true); + yield* cancelPendingRuntimeRequests(); + yield* terminalizeOpenForegroundTools(context, "failed"); + const minutes = Math.max(1, Math.round(timeoutMillis / 60_000)); + yield* finalizeTurn( + context, + "failed", + makeProviderFailure({ + class: "provider_error", + message: `${flavor.runtimeHarness ?? driver} sent nothing for ${minutes} ${minutes === 1 ? "minute" : "minutes"}, so T3 Code stopped the turn and restarted the agent. Send a message to continue.`, + }), + ); + return "retired" as const; + }), + ); + if (Option.isNone(retired) || retired.value !== "retired") { + return Option.getOrElse(retired, () => "done" as const); + } + yield* Effect.logWarning("orchestration-v2.acp-prompt-inactive", { + driver, + providerSessionId: input.providerSessionId, + providerTurnId: context.providerTurnId, + timeoutMillis, + }); + if (runtimeScope !== undefined) { + yield* Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore); + } + return "retired" as const; + }); + + const watchPromptInactivity = Effect.fn("AcpAdapterV2.watchPromptInactivity")(function* ( + context: ActiveAcpTurn, + generation: number, + timeoutMillis: number, + ) { + yield* Deferred.await(context.promptDispatched); + context.lastProgressAt = yield* Clock.currentTimeMillis; + while (true) { + const remaining = + context.lastProgressAt + timeoutMillis - (yield* Clock.currentTimeMillis); + if (remaining > 0) { + yield* Effect.sleep(Duration.millis(remaining)); + continue; + } + const outcome = yield* runtimeTransitionPermit.withPermit( + retireInactivePrompt(context, generation, timeoutMillis), + ); + if (outcome !== "rearm") return; + } + }); + const resolvePromptParts = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, sessionId: string, @@ -7606,6 +8049,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const providerTurnId = deriveProviderTurnId(nativeTurnId); const completed = yield* Deferred.make(); const promptWireSettled = yield* Deferred.make(); + const promptDispatched = yield* Deferred.make(); + const promptStarted = yield* Deferred.make(); const rememberedContextUsage = (yield* Ref.get(contextUsageBySessionId)).get( requestedSessionId, ); @@ -7661,8 +8106,14 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( promptSettledStatus: null, workAccepted: false, promptWireSettled, + promptDispatched, + promptSentAt: null, + promptStarted, promptUsage: undefined, backgroundFinalizeGeneration: 0, + nativePromptId: undefined, + lastProgressAt: 0, + inactivityWatchdog: undefined, }; const carryover = yield* Ref.getAndSet(carryoverSubagents, null); let rehydratedCarryoverSubagents: ReadonlyArray = []; @@ -7802,10 +8253,31 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? undefined : yield* flavor.watchPromptOutcome(requestedSessionId); const promptGeneration = yield* Ref.get(runtimeCallbackGeneration); - yield* runtime.prompt({ prompt: promptParts!.prompt }).pipe( + const promptOptions = { dispatched: context.promptDispatched }; + context.promptSentAt = yield* Clock.currentTimeMillis; + if (promptInactivityTimeoutMillis !== undefined) { + context.inactivityWatchdog = yield* watchPromptInactivity( + context, + promptGeneration, + promptInactivityTimeoutMillis, + ).pipe(Effect.forkIn(sessionScope)); + } + const compactSession = isCompactionMessage(turnInput.message) + ? flavor.compactSession + : undefined; + yield* ( + compactSession === undefined + ? runtime.prompt({ prompt: promptParts!.prompt }, promptOptions) + : Deferred.succeed(context.promptDispatched, undefined).pipe( + Effect.andThen(compactSession(runtime, requestedSessionId)), + Effect.as({ stopReason: "end_turn" } satisfies EffectAcpSchema.PromptResponse), + ) + ).pipe( Effect.tap(() => Ref.update(promptInstructionStates, (current) => { - if (promptParts?.instructionState === undefined) return current; + // Native compaction sends no prompt, so no instructions reached the agent. + if (promptParts?.instructionState === undefined || compactSession !== undefined) + return current; const updated = new Map(current); updated.set(requestedSessionId, promptParts.instructionState); return updated; @@ -8201,7 +8673,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ), ), startTurn, - ...(flavor.supportsCompaction === true + ...(flavor.supportsCompaction === true || flavor.compactSession !== undefined ? { compactThread: (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => startTurn({ @@ -8210,13 +8682,69 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }), } : {}), - steerTurn: (turnInput) => - Effect.fail( - new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ - driver, - providerThreadId: turnInput.providerThread.id, - }), - ), + steerTurn: Effect.fn("AcpAdapterV2.steerTurn")( + function* (turnInput) { + const steering = flavor.steering; + // The agent takes text only, so a steer with attachments waits for the next turn. + if (steering === undefined || turnInput.message.attachments.length > 0) { + return yield* new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ + driver, + providerThreadId: turnInput.providerThread.id, + }); + } + const context = yield* Ref.get(activeTurn); + if ( + context === null || + context.finalized || + context.providerTurnId !== turnInput.providerTurnId + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `Provider turn ${turnInput.providerTurnId} is not active and cannot be steered.`, + }); + } + // An agent that drops an early cancel drops an early steer too. + const startHold = flavor.cancelAfterPromptStarts; + if (startHold !== undefined) { + yield* Deferred.await(context.promptStarted).pipe( + Effect.timeoutOption(startHold.bound), + ); + } + const steerId = yield* steering.send( + runtime, + context.nativeThreadId, + turnInput.message.text, + ); + const injected = steerInjection(steerId); + // The agent reads a steer at its next step. One that lands after the last + // step is dropped, and the turn settles right after; the orchestrator then + // sends it as a follow-up. The bound keeps Stop from queueing behind a tool. + const outcome = yield* Effect.raceFirst( + Deferred.await(injected).pipe(Effect.as("injected" as const)), + Deferred.await(context.promptWireSettled).pipe(Effect.as("settled" as const)), + ).pipe(Effect.timeoutOption(STEER_DELIVERY_BOUND)); + steerInjections.delete(steerId); + if (Option.contains(outcome, "settled") && !(yield* Deferred.isDone(injected))) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `Provider turn ${turnInput.providerTurnId} ended before it read the steering message.`, + }); + } + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSteerRunUnsupportedError" + ? cause + : new ProviderAdapter.ProviderAdapterSteerRunError({ + driver, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + cause, + }), + ), + ), + ), interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")( function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) { return yield* Effect.uninterruptibleMask((restore) => @@ -8478,7 +9006,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* Deferred.succeed(teardownBarrier!, undefined).pipe(Effect.ignore); } else { // Settled soft interrupts have no prompt to cancel; cancel could kill background subagents. - if (!settledSoftInterrupt) { + if (!settledSoftInterrupt && (yield* promptStillRunsAfterHold(context))) { yield* runtime.cancel; } if (restartRuntime && flavor.restartRuntimeAfterInterrupt === true) { @@ -8732,7 +9260,13 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( detail: "ACP driver did not negotiate session/fork", }); } - if (forkInput.providerTurnId !== undefined) { + const forkMeta = forkFromTurnMetaFor({ + driver, + flavor, + canForkFromTurn: capabilities.threads.canForkFromTurn, + forkInput, + }); + if (forkMeta === null) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver, detail: "ACP session/fork can only fork the current session head", @@ -8747,6 +9281,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const forked = yield* runtime.forkSession( sourceSessionId, acpMcpActivation(mcpContext), + forkMeta, ); rememberTerminalEnvironment( forked.sessionId, diff --git a/packages/provider-acp/src/server/mcpOverAcpBridge.ts b/packages/provider-acp/src/server/mcpOverAcpBridge.ts index 44814891ab1a9..95c3d09e08e72 100644 --- a/packages/provider-acp/src/server/mcpOverAcpBridge.ts +++ b/packages/provider-acp/src/server/mcpOverAcpBridge.ts @@ -1,5 +1,6 @@ -// @effect-diagnostics preferSchemaOverJson:off +// @effect-diagnostics globalJsonInEffect:off import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import type * as AcpSchema from "effect-acp/compat"; @@ -23,15 +24,20 @@ interface Connection { nextRequestId: number; } -export class AcpMcpOverAcpError extends Error { - readonly _tag = "AcpMcpOverAcpError"; - override readonly name = "AcpMcpOverAcpError"; -} +export class AcpMcpOverAcpError extends Schema.TaggedError()( + "AcpMcpOverAcpError", + { message: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) {} + +const isAcpMcpOverAcpError = Schema.is(AcpMcpOverAcpError); const bridgeError = (cause: unknown): AcpMcpOverAcpError => - cause instanceof AcpMcpOverAcpError + isAcpMcpOverAcpError(cause) ? cause - : new AcpMcpOverAcpError(cause instanceof Error ? cause.message : String(cause), { cause }); + : new AcpMcpOverAcpError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }); const asEnvelope = (value: unknown): JsonRpcEnvelope | null => typeof value === "object" && value !== null && !Array.isArray(value) @@ -88,7 +94,11 @@ export const makeAcpMcpOverAcpBridge = Effect.fn("AcpMcpOverAcpBridge.make")(fun Effect.suspend(() => { const connection = connections.get(connectionId); return connection === undefined - ? Effect.fail(new AcpMcpOverAcpError(`Unknown MCP-over-ACP connection "${connectionId}".`)) + ? Effect.fail( + new AcpMcpOverAcpError({ + message: `Unknown MCP-over-ACP connection "${connectionId}".`, + }), + ) : Effect.succeed(connection); }); @@ -107,7 +117,7 @@ export const makeAcpMcpOverAcpBridge = Effect.fn("AcpMcpOverAcpBridge.make")(fun Effect.gen(function* () { const body = JSON.stringify(message); if (Buffer.byteLength(body) > MAX_MESSAGE_BYTES) { - return yield* Effect.fail(new AcpMcpOverAcpError("MCP-over-ACP message exceeds 8 MiB.")); + return yield* new AcpMcpOverAcpError({ message: "MCP-over-ACP message exceeds 8 MiB." }); } const response = yield* Effect.tryPromise({ try: (signal) => @@ -134,9 +144,9 @@ export const makeAcpMcpOverAcpBridge = Effect.fn("AcpMcpOverAcpBridge.make")(fun yield* Effect.promise( () => response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), ); - return yield* Effect.fail( - new AcpMcpOverAcpError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), - ); + return yield* new AcpMcpOverAcpError({ + message: `T3 Code MCP endpoint responded with HTTP ${response.status}.`, + }); } const payloads = [...(yield* Stream.runCollect(responsePayloads(response, bridgeError)))]; for (const payload of payloads) { @@ -169,11 +179,9 @@ export const makeAcpMcpOverAcpBridge = Effect.fn("AcpMcpOverAcpBridge.make")(fun catch: bridgeError, }); if (!response.ok && response.status !== 404) { - return yield* Effect.fail( - new AcpMcpOverAcpError( - `T3 Code MCP endpoint rejected disconnect with HTTP ${response.status}.`, - ), - ); + return yield* new AcpMcpOverAcpError({ + message: `T3 Code MCP endpoint rejected disconnect with HTTP ${response.status}.`, + }); } yield* Effect.promise( () => response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), @@ -185,12 +193,12 @@ export const makeAcpMcpOverAcpBridge = Effect.fn("AcpMcpOverAcpBridge.make")(fun return { connect: Effect.fn("mcpOverAcpBridge.connect")(function* (request) { if (request.serverId !== "t3-code") { - return yield* Effect.fail( - new AcpMcpOverAcpError(`Unknown ACP MCP server "${request.serverId}".`), - ); + return yield* new AcpMcpOverAcpError({ + message: `Unknown ACP MCP server "${request.serverId}".`, + }); } if (connections.size >= MAX_CONNECTIONS) { - return yield* Effect.fail(new AcpMcpOverAcpError("Too many MCP-over-ACP connections.")); + return yield* new AcpMcpOverAcpError({ message: "Too many MCP-over-ACP connections." }); } const connectionId = yield* options.allocateConnectionId; connections.set(connectionId, { @@ -213,12 +221,12 @@ export const makeAcpMcpOverAcpBridge = Effect.fn("AcpMcpOverAcpBridge.make")(fun const response = payloads.find((payload) => asEnvelope(payload)?.id === id); const envelope = asEnvelope(response); if (envelope === null) { - return yield* Effect.fail( - new AcpMcpOverAcpError("MCP server did not return a matching response."), - ); + return yield* new AcpMcpOverAcpError({ + message: "MCP server did not return a matching response.", + }); } if (envelope.error !== undefined) { - return yield* Effect.fail(new AcpMcpOverAcpError(errorMessage(envelope.error))); + return yield* new AcpMcpOverAcpError({ message: errorMessage(envelope.error) }); } return (envelope.result ?? null) as AcpSchema.MessageMcpResponse; }), diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index c35789ec47dc4..086628fcfae94 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -1366,6 +1366,20 @@ describe("extractMcpToolCallIdentity", () => { } }); + it("recovers T3 identity from Kiro's @-scoped titles", () => { + for (const tool of ["html_render", "t3_thread_read", "delegate_task"]) { + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "kiro-1", + kind: "other", + title: `@t3-code/${tool}`, + status: "pending", + rawInput: { tool_id: `t3-code::${tool}`, arguments: { threadId: "thread-1" } }, + }); + expect(extractMcpToolCallIdentity(toolCall), tool).toEqual({ server: "t3-code", tool }); + } + }); + it("does not claim an agent's own tool that shares a T3 tool name", () => { const toolCall = toolCallFromUpdate({ sessionUpdate: "tool_call", diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index 49fd56cc19d6c..bc418eefcb228 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -1067,9 +1067,9 @@ function acpMcpFallbackInput(value: string | undefined): Record return undefined; } -// Known T3 inventory gates matches, so server-name separators can stay loose. +// Known T3 inventory gates matches, so server-name separators can stay loose. Kiro titles "@t3-code/". const T3_MCP_TITLE_CALL = - /^(?:mcp[-_]{1,2})?t3[-_ ]?code[-_.:/ ]{1,3}(?[A-Za-z0-9][A-Za-z0-9_.-]*)(?::.*)?$/i; + /^(?:@|mcp[-_]{1,2})?t3[-_ ]?code[-_.:/ ]{1,3}(?[A-Za-z0-9][A-Za-z0-9_.-]*)(?::.*)?$/i; const T3_MCP_TITLE_SUFFIX_CALL = /^(?[A-Za-z0-9][A-Za-z0-9_.-]*?)(?: \(t3[-_ ]?code MCP Server\)(?::|$)|[-_.]t3[-_ ]?code$)/i; @@ -1095,6 +1095,18 @@ export function extractMcpToolCallIdentity( if (meta?.is_mcp_tool_call === true && server.length > 0 && tool.length > 0) { return { server, tool }; } + // Kiro names the server in `_meta.kiro.serverName` and the call in `rawInput.tool_id` + // ("::"), with the tool's own input under `rawInput.arguments`. + const kiroServer = isRecord(meta?.kiro) ? meta.kiro.serverName : undefined; + const kiroToolId = typeof rawInput?.tool_id === "string" ? rawInput.tool_id.trim() : ""; + if ( + typeof kiroServer === "string" && + kiroServer.length > 0 && + kiroToolId.startsWith(`${kiroServer}::`) && + kiroToolId.length > kiroServer.length + 2 + ) { + return { server: kiroServer, tool: kiroToolId.slice(kiroServer.length + 2) }; + } const claudeCode = isRecord(meta?.claudeCode) ? meta.claudeCode : undefined; const gooseToolCall = isRecord(meta?.goose) ? isRecord(meta.goose.toolCall) diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index e0383a67a4cad..032bc81525e5c 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -181,7 +181,7 @@ } }, "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "effect-tsgo tsrs --noEmit", "test": "vp test run --config ../../vite.config.ts --dir ." }, "dependencies": { diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 8aae3a40ccae6..ccb2817948f0a 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -91,6 +91,8 @@ export const ProviderAdapterV2Event = Schema.Union([ type: Schema.Literal("provider_thread.updated"), driver: ProviderDriverKind, providerThread: OrchestrationV2ProviderThread, + /** Work remaining at this point in the event stream, including native continuations. */ + pendingBackgroundWork: Schema.optional(Schema.Boolean), retainedNativeTurnIds: Schema.optional(Schema.Array(Schema.String)), }), Schema.Struct({ @@ -228,6 +230,24 @@ export class ProviderAdapterResumeThreadError extends Schema.TaggedError()( + "ProviderAdapterThreadInUseError", + { + driver: ProviderDriverKind, + providerThreadId: ProviderThreadId, + detail: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return this.detail; + } +} + export class ProviderAdapterEnsureThreadError extends Schema.TaggedError()( "ProviderAdapterEnsureThreadError", { @@ -399,11 +419,21 @@ export class ProviderAdapterProtocolError extends Schema.TaggedError()( + "ProviderAdapterSessionStoppedError", + { driver: ProviderDriverKind }, +) { + override get message(): string { + return `No running ${this.driver} session owns this thread.`; + } +} + export const ProviderAdapterV2Error = Schema.Union([ ProviderAdapterCapabilitiesError, ProviderAdapterOpenSessionError, ProviderAdapterCloseSessionError, ProviderAdapterResumeThreadError, + ProviderAdapterThreadInUseError, ProviderAdapterEnsureThreadError, ProviderAdapterReadThreadSnapshotError, ProviderAdapterRollbackThreadError, @@ -416,6 +446,7 @@ export const ProviderAdapterV2Error = Schema.Union([ ProviderAdapterRuntimeRequestResponseError, ProviderAdapterEventStreamError, ProviderAdapterProtocolError, + ProviderAdapterSessionStoppedError, ]); export type ProviderAdapterV2Error = typeof ProviderAdapterV2Error.Type; @@ -531,6 +562,8 @@ export interface ProviderAdapterV2HistoricalContext { export interface ProviderAdapterV2McpTool { readonly name: string; + // Apps reject a `hostContext.toolInfo.tool` without one. + readonly inputSchema: unknown; readonly _meta?: unknown; readonly annotations?: unknown; } diff --git a/packages/provider-core/src/server/failure.test.ts b/packages/provider-core/src/server/failure.test.ts index 84cc14dc2e594..04829ad8f263b 100644 --- a/packages/provider-core/src/server/failure.test.ts +++ b/packages/provider-core/src/server/failure.test.ts @@ -146,6 +146,16 @@ it("keeps unrecognized reasons and provider text out of category messages", () = { _tag: "ProviderAdapterProtocolError", reason: "private-config" }, { _tag: "ProviderAdapterProtocolError", reason: "constructor" }, { _tag: "constructor", cause: "private-config" }, + { + _tag: "CodexAppServerRequestError", + method: "thread/goal/set", + message: "thread not found: private-thread", + }, + { + _tag: "CodexAppServerRequestError", + method: "turn/start", + message: "goal objective must be at most 4000 characters", + }, ]) { assert.equal( makeProviderFailure({ cause: { _tag: "ProviderAdapterTurnStartError", cause } }).message, @@ -176,6 +186,23 @@ it("uses the innermost category without carrying an outer reason into it", () => ); }); +it("explains a native thread another client is writing to", () => { + const inUse = new ProviderAdapter.ProviderAdapterThreadInUseError({ + driver: ProviderDriverKind.make("codex"), + providerThreadId: ProviderThreadId.make("provider-thread:in-use"), + detail: "This conversation is open in another Codex client.", + cause: new Error("thread provider-thread:in-use already has an active writer"), + }); + const cause = new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: ProviderDriverKind.make("codex"), + threadId: ThreadId.make("thread:in-use"), + providerThreadId: ProviderThreadId.make("provider-thread:in-use"), + runId: RunId.make("run:in-use"), + cause: inUse, + }); + assert.equal(makeProviderFailure({ cause: Cause.fail(cause) }).message, inUse.message); +}); + it("does not expose defect text nested inside a known error category", () => { const failure = makeProviderFailure({ cause: { diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index 9bdebc55d373b..edaf0aa97a743 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -61,6 +61,10 @@ const PROVIDER_FAILURE_CATEGORIES: Partial< }, }; +// codex_protocol::protocol::validate_thread_goal_objective +const CODEX_GOAL_VALIDATION_MESSAGE = + /^goal objective must (?:not be empty|be at most \d{1,6} characters)$/u; + const PROVIDER_FAILURE_REASONS: Record = { previous_turn_active: "the previous turn is still running", attachment_unreadable: "an attachment could not be read", @@ -107,9 +111,25 @@ function curatedCauseMessages(cause: unknown): { case "ClaudeAppendSystemPromptMissingValueError": case "ClaudeSettingsLaunchArgError": case "ClaudeMcpRegistrationTimeoutError": + case "ClaudeCodeRootRefusalError": case "CursorModelUnavailableError": + case "CodexGoalObjectiveTooLongError": case "ProviderSetupError": + case "ProviderAdapterThreadInUseError": return { known: stringField(cause, "message"), category: categoryMessage() }; + case "CodexAppServerRequestError": { + // Only Codex's fixed-shape goal validation text is actionable and + // safe to show; other request error text stays hidden. + const rejection = stringField(cause, "message"); + if ( + (cause as Record).method === "thread/goal/set" && + rejection !== undefined && + CODEX_GOAL_VALIDATION_MESSAGE.test(rejection) + ) { + return { known: `Codex rejected the goal: ${rejection}.`, category: categoryMessage() }; + } + break; + } case "AcpProcessExitedError": { const exit = `provider process exited${processExitStatus(cause)}`; const stage = category === undefined ? `The ${exit}` : `${category.summary}: the ${exit}`; diff --git a/packages/provider-core/src/server/handoffBudget.ts b/packages/provider-core/src/server/handoffBudget.ts index 4658bc44fc6d3..b1c98806f29d9 100644 --- a/packages/provider-core/src/server/handoffBudget.ts +++ b/packages/provider-core/src/server/handoffBudget.ts @@ -147,7 +147,7 @@ export function historicalMessage( text = item.message; break; case "file_change": - text = `File change: ${item.fileName}`; + text = `File change: ${item.changes?.map((change) => change.path).join(", ") ?? item.fileName}`; break; case "proposed_plan": text = item.markdown; diff --git a/packages/provider-core/src/server/maintenance.ts b/packages/provider-core/src/server/maintenance.ts index b9f793cc8bf72..9d2a63c5bfbe3 100644 --- a/packages/provider-core/src/server/maintenance.ts +++ b/packages/provider-core/src/server/maintenance.ts @@ -12,6 +12,8 @@ export interface ProviderMaintenanceCapabilities { * `latestVersion`, then npm. */ readonly lookupLatestVersion?: Effect.Effect; + /** Separates installer release channels that share an update command. */ + readonly latestVersionCacheKey?: string; readonly compareVersions?: (current: string, latest: string) => number; /** Resolved executable path for advisories when no update command is proven. */ readonly installPath?: string; diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 7883d827d4e64..9447742ca354b 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -1,12 +1,13 @@ // @effect-diagnostics nodeBuiltinImport:off import { expect, it } from "@effect/vitest"; import * as NodeFS from "node:fs"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -44,8 +45,8 @@ const driver = (value: string) => ProviderDriverKind.make(value); // Shell stubs use POSIX paths; Windows temp paths cannot split on colon. const windowsHost = HostProcess.Platform.defaultValue() === "win32"; const makeTempDir = (name: string) => - Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4()), + SecureRandom.SecureRandom.pipe( + Effect.flatMap((random) => random.uuidV4()), Effect.map((id) => NodePath.join(NodeFS.realpathSync(NodeOS.tmpdir()), `${name}-${id}`)), ); const isNativeTestCommandPath = @@ -131,7 +132,11 @@ function stdoutSpawner( } it.layer( - NodeServices.layer.pipe(Layer.merge(Layer.succeed(HostProcess.Environment, { PATH: "" }))), + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.succeed(HostProcess.Environment, { PATH: "" }), + ), )("providerMaintenance", (it) => { it.effect("prefers the installer's own latest version over the npm registry", () => resolveLatestProviderVersion({ ...manualPackageTool, latestVersion: "1.2.0" }).pipe( @@ -706,7 +711,7 @@ it.layer( maintenanceCapabilities: capabilities, }), ).toMatchObject({ status: "current", canUpdate: false, canInstallVersion: false }); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("stays unknown when choco search fails instead of asking npm", () => @@ -737,7 +742,7 @@ it.layer( ); expect(latest).toBeNull(); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each([ @@ -1127,6 +1132,34 @@ it.layer( }, ); + it.effect.skipIf(!symlinksSupported || windowsHost)( + "updates an npm-owned launcher in a native install location with npm", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-native-npm"); + const prefix = NodePath.join(tempDir, ".local"); + const packageBin = NodePath.join("node_modules", "@example", "native-package-tool", "bin"); + writeExecutable(NodePath.join(prefix, "lib", packageBin, "native-package-tool.exe")); + const launcher = NodePath.join(prefix, "bin", "native-package-tool"); + NodeFS.mkdirSync(NodePath.dirname(launcher), { recursive: true }); + NodeFS.symlinkSync( + NodePath.join("..", "lib", packageBin, "native-package-tool.exe"), + launcher, + ); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + nativePackageToolUpdate, + { binaryPath: launcher, env: { PATH: "" } }, + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); + + expect(capabilities.update).toMatchObject({ + executable: "npm", + args: expect.arrayContaining(["--prefix", prefix]), + lockKey: `npm-global:${normalizeCommandPath(prefix)}`, + }); + }), + ); + it.effect.skipIf(!symlinksSupported)( "prefers npm ownership over the Node keg the package lives under", () => diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 3017bf8267594..3fbe9e28fc28a 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -532,10 +532,11 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( : {}), }) : manual; - // A native-looking launcher can still delegate to a mise-owned install. + // A native-looking launcher can still delegate to an npm- or mise-owned install. if ( nativeUpdate?.isCommandPath && commandPaths.some(nativeUpdate.isCommandPath) && + !npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName) && !commandPaths.some(isMiseCommandPath) && !(yield* isMiseWrapperScript(context)) ) { @@ -980,6 +981,7 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack */ function installerLatestVersionKey(capabilities: ProviderMaintenanceCapabilities) { if (!capabilities.lookupLatestVersion) return null; + if (capabilities.latestVersionCacheKey) return capabilities.latestVersionCacheKey; const update = capabilities.update; if (update) return [update.lockKey, update.executable, ...update.args].join("\0"); return capabilities.installPath diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 19e86de2cb15d..2e08213cb8f77 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -8,7 +8,7 @@ export const T3_CODE_ORCHESTRATION_SECTIONS: ReadonlyArray = [ Ordinary coding tasks need no T3 tools. -- Subagents: use your native tool when the child uses your provider, a model it offers, and no settings beyond yours, and it finishes in this turn (Claude \`Agent\` always with \`run_in_background:false\`; Codex \`spawn_agent\`, then wait). Use \`delegate_task\` for another provider's model, user-asked settings, a separate checkout (\`isolation:"worktree"\`), background work, or \`delegate_task\` by name. A native type running another provider's model counts too. Workflow only when asked. +- Subagents: use your native tool when the child uses your provider, a model it offers, and no settings beyond yours (Claude \`Agent\`, \`run_in_background:false\`; Codex \`spawn_agent\`, then wait). Use \`delegate_task\` for another provider's model, user-asked settings, a separate checkout (\`isolation:"worktree"\`), or \`delegate_task\` by name. A native type on another provider's model counts too. Background agent: \`delegate_task\`, never a background \`Agent\`. Workflow only when asked. - Call \`orchestrator_capabilities\` once per thread, before the first \`delegate_task\`. Again only after an error or a user-named provider change. Settings go in \`target.options\`. Provider or model unavailable: say so. Never switch unasked, or fall back to native tools, Workflow, or CLIs. Never make a child's checkout in the shell or in \`task\`. - Every new delegate review or re-check is a new \`delegate_task\` (brief, findings, objections), never a \`t3_thread_send\`. Fix small review findings yourself; start no delegate for them. When told to proceed without a delegate, or to run fewer, \`task_cancel\` the extras. Start no replacement. - Follow a running delegate's \`next\`. Do not poll it (\`task_status\`, \`sleep\`, watchers, Monitor, ScheduleWakeup). Stop a delegate run you replace or no longer need with \`task_cancel\`, never \`t3_thread_interrupt\` on a \`childThreadId\`. @@ -18,7 +18,7 @@ Ordinary coding tasks need no T3 tools. - Another machine: \`t3_environment_links\` lists linked ones. Pass its \`environmentId\` to a T3 tool to act there. Ids are then that machine's, so pass \`threadId\`. - A machine the user names that is not linked: tell them to link its workspace in T3 Coder's workspace Links settings. - In your reply, mention a thread by its \`link\`, verbatim. Never a plain title or bare id. -- A secret: call \`request_secret\` yourself. Pass its \`secretRef\` to a T3 tool, or use \`delivery: "file"\` for a shell command. Never ask in chat or invent one. When asked only to schedule, \`schedule_task\` without running its prompt. +- A login, token, PIN or pairing code only the user can give: call \`request_secret\`, not a chat ask or a handoff. Not for access you have. When asked only to schedule, \`schedule_task\` without running its prompt. - T3 tools may load late. Try the name once before you call it absent.`, `### Visuals and preview diff --git a/packages/provider-core/src/server/subagentProjection.test.ts b/packages/provider-core/src/server/subagentProjection.test.ts new file mode 100644 index 0000000000000..767764db0cae5 --- /dev/null +++ b/packages/provider-core/src/server/subagentProjection.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { subagentThreadTitle } from "./subagentProjection.ts"; + +describe("subagentThreadTitle", () => { + it("clips a long prompt without splitting a surrogate pair", () => { + const title = (prompt: string) => + subagentThreadTitle({ parentTitle: "Parent", prompt, ordinal: 1 }); + + expect(title(`${"a".repeat(68)}😀 and more work.`)).toBe(`${"a".repeat(68)}...`); + expect(title(`${"a".repeat(67)}😀 and more work.`)).toBe(`${"a".repeat(67)}😀...`); + }); +}); diff --git a/packages/provider-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts index 3775818892502..dbd929192e04d 100644 --- a/packages/provider-core/src/server/subagentProjection.ts +++ b/packages/provider-core/src/server/subagentProjection.ts @@ -39,8 +39,14 @@ export function subagentThreadTitle(input: { if (detail === undefined) { return `${input.parentTitle} subagent ${input.ordinal}`; } - const clipped = detail.length > 72 ? `${detail.slice(0, 69)}...` : detail; - return clipped; + if (detail.length <= 72) return detail; + let end = 69; + const previous = detail.charCodeAt(end - 1); + const next = detail.charCodeAt(end); + if (previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) { + end -= 1; + } + return `${detail.slice(0, end)}...`; } export function makeSubagentChildThread(input: { diff --git a/packages/provider-core/src/server/textGenerationUtils.test.ts b/packages/provider-core/src/server/textGenerationUtils.test.ts index d82bb50df862a..885e09c4fe77b 100644 --- a/packages/provider-core/src/server/textGenerationUtils.test.ts +++ b/packages/provider-core/src/server/textGenerationUtils.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { sanitizeCommitSubject, sanitizeThreadTitle } from "./textGenerationUtils.ts"; +import { limitSection, sanitizeCommitSubject, sanitizeThreadTitle } from "./textGenerationUtils.ts"; + +describe("limitSection", () => { + it("cuts a long section without splitting an emoji", () => { + expect(limitSection(`${"a".repeat(9)}🚀 more`, 10)).toBe(`${"a".repeat(9)}\n\n[truncated]`); + }); +}); describe("sanitizeCommitSubject", () => { // regression: #19181 truncation split grapheme clusters at the subject limit diff --git a/packages/provider-core/src/server/textGenerationUtils.ts b/packages/provider-core/src/server/textGenerationUtils.ts index 072999fd74969..d2afb675ae936 100644 --- a/packages/provider-core/src/server/textGenerationUtils.ts +++ b/packages/provider-core/src/server/textGenerationUtils.ts @@ -19,7 +19,9 @@ export function toJsonSchemaObject(schema: Schema.Top): unknown { export function limitSection(value: string, maxChars: number): string { if (value.length <= maxChars) return value; - const truncated = value.slice(0, maxChars); + // Half an emoji serializes to a lone surrogate escape, which provider JSON parsers reject. + const last = value.charCodeAt(maxChars - 1); + const truncated = value.slice(0, last >= 0xd800 && last <= 0xdbff ? maxChars - 1 : maxChars); return `${truncated}\n\n[truncated]`; } diff --git a/packages/provider-core/src/server/threadTitleContext.test.ts b/packages/provider-core/src/server/threadTitleContext.test.ts index 253f167c639c0..4c19d6f573134 100644 --- a/packages/provider-core/src/server/threadTitleContext.test.ts +++ b/packages/provider-core/src/server/threadTitleContext.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ChatAttachmentId } from "@t3tools/contracts"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; -import { formatThreadTitleContext } from "./threadTitleContext.ts"; +import { formatThreadTitleContext, limitTitleMessage } from "./threadTitleContext.ts"; describe("thread title context", () => { it.each(["", "Ultrathink:\n"])("keeps attachments without bootstrap text (%j)", (prefix) => { @@ -54,4 +54,9 @@ describe("thread title context", () => { expect(result.message).not.toContain("System instructions"); expect(result.message.match(/USER:/g)).toHaveLength(1); }); + + it("cuts both ends of a long message without splitting an emoji", () => { + const result = limitTitleMessage(`${"a".repeat(9)}🚀${"b".repeat(20)}🚀${"c".repeat(9)}`, 41); + expect(result).toBe(`${"a".repeat(9)}\n[Content truncated]\n${"c".repeat(9)}`); + }); }); diff --git a/packages/provider-core/src/server/threadTitleContext.ts b/packages/provider-core/src/server/threadTitleContext.ts index f1840891e96bb..1bff106003958 100644 --- a/packages/provider-core/src/server/threadTitleContext.ts +++ b/packages/provider-core/src/server/threadTitleContext.ts @@ -17,8 +17,13 @@ export function limitTitleMessage(text: string, budget: number): string { if (text.length <= budget) return text; if (budget <= TRUNCATED.length) return ""; const available = budget - TRUNCATED.length; - const head = Math.ceil(available / 2); - const tail = available - head; + let head = Math.ceil(available / 2); + let tail = available - head; + // Half an emoji serializes to a lone surrogate escape, which provider JSON parsers reject. + const last = text.charCodeAt(head - 1); + if (last >= 0xd800 && last <= 0xdbff) head -= 1; + const first = text.charCodeAt(text.length - tail); + if (tail > 0 && first >= 0xdc00 && first <= 0xdfff) tail -= 1; return `${text.slice(0, head)}${TRUNCATED}${tail > 0 ? text.slice(-tail) : ""}`; } diff --git a/packages/provider-pi/package.json b/packages/provider-pi/package.json index 7387a33303f68..e7ed280e410f4 100644 --- a/packages/provider-pi/package.json +++ b/packages/provider-pi/package.json @@ -17,7 +17,7 @@ } }, "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "effect-tsgo tsrs --noEmit", "test": "vp test run --config ../../vite.config.ts --dir ." }, "dependencies": { diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 5c94de3040209..546169a35b3d5 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -161,10 +161,11 @@ interface FakePi { readonly vetoNextSwitch: () => void; readonly queueState: (data: Record) => void; readonly setStreaming: (streaming: boolean) => void; + readonly setCompacting: (compacting: boolean) => void; readonly deferNextState: () => void; readonly resolveDeferredState: (data: unknown) => Effect.Effect; readonly failNextState: () => void; - readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void; + readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork" | "abort") => void; readonly deferNextModelSelection: () => void; readonly queueModels: (models: ReadonlyArray) => void; readonly vetoNextNewSession: () => void; @@ -179,7 +180,7 @@ interface FakePi { readonly closeStdout: Effect.Effect; readonly lastSpawn: () => { readonly args: ReadonlyArray; - readonly env: NodeJS.ProcessEnv; + readonly env: NonNullable; }; } @@ -197,6 +198,10 @@ const recordedIdleState = (sessionFile: string) => ({ pendingMessageCount: 0, }); +/** Pi 1.4.0 rejects every prompt while compaction runs, including steers. */ +const PI_COMPACTING_PROMPT_ERROR = + "Cannot submit a prompt while compaction is in progress. Wait for compaction to finish and retry."; + const makeFakePi: Effect.Effect = Effect.gen(function* () { const stdout = yield* Queue.unbounded(); const requests = yield* Queue.unbounded(); @@ -215,6 +220,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { let sessionGeneration = 0; let models: ReadonlyArray = []; let isStreaming: boolean | null = null; + let isCompacting = false; let clearQueue: Parameters[0] = { steering: [], followUp: [] }; let stdinBuffer = ""; @@ -228,7 +234,10 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { }); const respondTo = (record: PiRpcRecord): PiRpcRecord | null => { - if (typeof record["id"] !== "string" && !(record["type"] === "prompt" && isStreaming !== null)) + if ( + typeof record["id"] !== "string" && + !(record["type"] === "prompt" && (isStreaming !== null || isCompacting)) + ) return null; const base = { type: "response", @@ -251,6 +260,9 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { }, }; case "prompt": + if (isCompacting) { + return { ...base, success: false, error: PI_COMPACTING_PROMPT_ERROR }; + } if (isStreaming && record["streamingBehavior"] === undefined) { return { ...base, @@ -316,7 +328,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { } }); - let lastSpawn: { readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv } = { + let lastSpawn: ReturnType = { args: [], env: {}, }; @@ -324,7 +336,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { Effect.sync(() => { if (ChildProcess.isStandardCommand(command)) { lastSpawn = { - args: command.args, + args: command.args.map(String), env: command.options.env ?? {}, }; } @@ -397,6 +409,9 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { setStreaming: (streaming) => { isStreaming = streaming; }, + setCompacting: (compacting) => { + isCompacting = compacting; + }, queueStats: (data) => statsQueue.push(data), setClearQueue: (result) => { clearQueue = result; @@ -791,7 +806,7 @@ describe("PiAdapterV2", () => { ); it.effect.each([false, true])( - "retains native wake ownership after a rejected joining prompt, compaction aborted=%s", + "retains native wake ownership and delivers a joining prompt held through compaction, aborted=%s", (aborted) => Effect.gen(function* () { const fake = yield* makeFakePi; @@ -818,9 +833,6 @@ describe("PiAdapterV2", () => { yield* fake.emit({ type: "agent_settled" }); yield* fake.emit({ type: "compaction_start", reason: "manual" }); yield* startTurn(runtime, providerThread); - yield* fake.takeRequest("prompt"); - const rejection = "Cannot submit a prompt while compaction is in progress."; - yield* fake.emit({ type: "response", command: "prompt", success: false, error: rejection }); yield* fake.emit({ type: "extension_ui_request", id: "native-compaction-dialog", @@ -836,22 +848,21 @@ describe("PiAdapterV2", () => { decision: "accept", }); yield* fake.takeRequest("extension_ui_response"); - if (aborted) { - yield* fake.emit({ type: "compaction_end", result: null, aborted: true }); - const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); - assert.isTrue( - terminal.type === "turn.terminal" && - terminal.status === "failed" && - terminal.failure.message === rejection, - ); - return; - } + assert.isFalse(fake.allRequests().some((request) => request["type"] === "prompt")); + yield* fake.emit( + aborted + ? { type: "compaction_end", result: null, aborted: true } + : { + type: "compaction_end", + result: { summary: "Native compaction finished" }, + willRetry: true, + }, + ); + const prompt = yield* fake.takeRequest("prompt"); + assert.equal(prompt["message"], "Hello pi"); fake.queueState({ isStreaming: true }); - yield* fake.emit({ - type: "compaction_end", - result: { summary: "Native compaction finished" }, - willRetry: true, - }); + yield* fake.emit({ type: "response", command: "prompt", success: true }); + yield* fake.takeRequest("get_state"); yield* fake.emit({ type: "agent_start" }); yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); yield* fake.emit({ @@ -864,13 +875,12 @@ describe("PiAdapterV2", () => { }); const message = yield* takeEvent((event) => event.type === "message.updated"); assert.isTrue(message.type === "message.updated" && message.message.runId !== null); - yield* fake.takeRequest("get_state"); yield* fake.emit({ type: "agent_settled" }); const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); - assert.isTrue( - terminal.type === "turn.terminal" && - terminal.status === "failed" && - terminal.failure.message === rejection, + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + assert.equal( + fake.allRequests().filter((request) => request["type"] === "prompt").length, + 1, ); }).pipe(Effect.scoped, Effect.provide(layerTest)), ); @@ -1523,7 +1533,10 @@ describe("PiAdapterV2", () => { it.effect("gives notify and extension-error items distinct ids in each thread", () => Effect.gen(function* () { const itemIds = new Set(); - for (const threadId of [THREAD_ID, ThreadId.make("thread-pi-test-2")]) { + for (const [threadId, extensionPath] of [ + [THREAD_ID, "/ext/a.ts"], + [ThreadId.make("thread-pi-test-2"), "/ext/a/index.ts"], + ] as const) { const fake = yield* makeFakePi; const { runtime, takeEvent } = yield* openRuntime(fake, "default", threadId); const providerThread = yield* runtime.ensureThread({ @@ -1548,7 +1561,7 @@ describe("PiAdapterV2", () => { method: "notify", message: "note", }); - yield* fake.emit({ type: "extension_error", extensionPath: "/ext/a.ts", error: "boom" }); + yield* fake.emit({ type: "extension_error", extensionPath, error: "boom" }); for (const prefix of ["notify:", "extension-error:"]) { const item = yield* takeEvent( (event) => @@ -1556,6 +1569,9 @@ describe("PiAdapterV2", () => { event.turnItem.nativeItemRef?.nativeId?.startsWith(prefix) === true, ); if (item.type === "turn_item.updated") itemIds.add(item.turnItem.id); + if (item.type === "turn_item.updated" && prefix === "extension-error:") { + assert.equal(item.turnItem.title, "a"); + } } } assert.equal(itemIds.size, 4); @@ -1581,6 +1597,9 @@ describe("PiAdapterV2", () => { arg === "--extension" ? [spawn.args[index + 1]] : [], ); assert.isFalse(spawn.args.includes("--no-extensions")); + // A thread's long-lived process keeps Pi's startup model refresh unless the user opts out. + assert.notInclude(spawn.args, "--offline"); + assert.isUndefined(spawn.env.PI_OFFLINE); assert.isTrue(extensions.some((path) => path?.endsWith("pi-t3-mcp-extension.ts"))); assert.equal(spawn.env.T3_MCP_URL, "http://127.0.0.1:43123/mcp"); assert.equal(spawn.env.T3_MCP_BEARER_TOKEN, "secret-pi-token"); @@ -1881,6 +1900,7 @@ describe("PiAdapterV2", () => { assert.equal(args[args.indexOf("--fork") + 1], FAKE_SESSION_FILE); assert.include(args, "--no-extensions"); assert.include(args, "--no-tools"); + assert.include(args, "--offline"); assert.notInclude(args, "--no-session"); assert.deepEqual( forkFake @@ -2308,7 +2328,10 @@ describe("PiAdapterV2", () => { if (event.type !== "turn_item.updated" || event.turnItem.type !== "dynamic_tool") return yield* Effect.die("Expected a read image tool item"); assert.deepEqual(event.turnItem.output, { content: imageContent, structuredContent }); - assert.equal(turnItemOutputText(event.turnItem), note); + assert.equal( + turnItemOutputText(event.turnItem), + JSON.stringify(structuredContent, null, 2), + ); } }).pipe(Effect.scoped, Effect.provide(layerTest)), ); @@ -2365,7 +2388,7 @@ describe("PiAdapterV2", () => { assert.equal(event.turnItem.toolName, tool.name); assert.equal(event.turnItem.status, "completed"); assert.deepEqual(event.turnItem.output, { content, structuredContent: metadata }); - assert.equal(turnItemOutputText(event.turnItem), "Screenshot captured"); + assert.equal(turnItemOutputText(event.turnItem), JSON.stringify(metadata, null, 2)); const image = toolOutputImages(event.turnItem.output)[0]; assert.equal(image?.mimeType, "image/png"); assert.deepEqual(Buffer.from(image!.data!, "base64"), pixels); @@ -4138,6 +4161,310 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect("delivers a turn-start prompt sent during detached compaction once it ends", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + const first = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(first.type === "turn.terminal" && first.status === "completed"); + + fake.setCompacting(true); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + // The dialog proves the pump observed compaction_start before the prompt. + yield* fake.emit({ + type: "extension_ui_request", + id: "detached-compaction-dialog", + method: "confirm", + title: "Continue?", + }); + yield* takeEvent((event) => event.type === "runtime_request.updated"); + yield* startTurn( + runtime, + providerThread, + "default", + [], + "Sent during compaction", + undefined, + 2, + ); + // Writes go out in order, so this round trip flushes any prompt sent before it. + yield* runtime.readThreadSnapshot({ providerThread }); + fake.setCompacting(false); + const compactionEnded = fake.allRequests().length; + assert.isFalse( + fake + .allRequests() + .some( + (request) => + request["type"] === "prompt" && request["message"] === "Sent during compaction", + ), + ); + yield* fake.emit({ + type: "compaction_end", + reason: "manual", + result: { summary: "smaller", tokensBefore: 10_000, estimatedTokensAfter: 2_000 }, + aborted: false, + willRetry: false, + }); + const delivered = yield* fake.takeRequest("prompt"); + assert.equal(delivered["message"], "Sent during compaction"); + yield* fake.emit({ type: "agent_start" }); + fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.equal( + fake + .allRequests() + .slice(compactionEnded) + .filter( + (request) => + request["type"] === "prompt" && request["message"] === "Sent during compaction", + ).length, + 1, + ); + assert.isTrue( + terminal.type === "turn.terminal" && terminal.status === "completed", + `turn ended ${terminal.type === "turn.terminal" ? terminal.status : terminal.type}`, + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect.each([ + { error: PI_COMPACTING_PROMPT_ERROR, retried: true }, + { error: "Model unavailable", retried: false }, + ])( + "retries a prompt Pi rejected because compaction started first, retried=$retried", + ({ error, retried }) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "response", command: "prompt", success: true }); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + yield* takeEvent((event) => event.type === "turn.terminal"); + + yield* startTurn(runtime, providerThread, "default", [], "Raced compaction", undefined, 2); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + yield* fake.emit({ + type: "response", + command: "prompt", + success: false, + error, + }); + if (!retried) { + const failed = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue( + failed.type === "turn.terminal" && + failed.status === "failed" && + failed.failure.message === error, + ); + return; + } + const compactionEnded = fake.allRequests().length; + yield* fake.emit({ type: "compaction_end", result: null, aborted: true }); + const retriedPrompt = yield* fake.takeRequest("prompt"); + assert.equal(retriedPrompt["message"], "Raced compaction"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue( + terminal.type === "turn.terminal" && terminal.status === "completed", + `turn ended ${terminal.type === "turn.terminal" ? terminal.status : terminal.type}`, + ); + assert.equal( + fake + .allRequests() + .slice(compactionEnded) + .filter((request) => request["type"] === "prompt").length, + 1, + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect.each([{ restart: false }, { restart: true }])( + "restarts Pi when Stop interrupts a prompt held for detached compaction, restart=$restart", + ({ restart }) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + yield* takeEvent((event) => event.type === "turn.terminal"); + + fake.setCompacting(true); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + // The dialog proves the pump observed compaction_start before the prompt. + yield* fake.emit({ + type: "extension_ui_request", + id: "held-prompt-dialog", + method: "confirm", + title: "Continue?", + }); + yield* takeEvent((event) => event.type === "runtime_request.updated"); + yield* startTurn(runtime, providerThread, "default", [], "Held prompt", undefined, 2); + // Writes go out in order, so this round trip flushes any prompt sent before it. + yield* runtime.readThreadSnapshot({ providerThread }); + assert.isFalse( + fake + .allRequests() + .some( + (request) => request["type"] === "prompt" && request["message"] === "Held prompt", + ), + ); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && + event.providerTurn.status === "running" && + event.providerTurn.ordinal === 2, + ); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + assert.isDefined(providerTurnId); + + const interruptedAt = fake.allRequests().length; + if (restart) { + fake.deferNextLifecycle("abort"); + const stop = yield* Effect.forkChild( + runtime.interruptTurn({ + providerThread, + providerTurnId: providerTurnId!, + requestRuntimeRestart: true, + }), + ); + const abort = yield* fake.takeRequest("abort"); + // The abort ends compaction before Pi exits; the stopped turn's prompt stays held. + fake.setCompacting(false); + yield* fake.emit({ + type: "compaction_end", + reason: "manual", + result: null, + aborted: true, + }); + yield* fake.emit({ + type: "extension_ui_request", + id: "after-compaction-dialog", + method: "confirm", + title: "Continue?", + }); + yield* takeEvent((event) => event.type === "runtime_request.updated"); + yield* fake.emit({ type: "response", id: abort["id"], command: "abort", success: true }); + yield* Fiber.join(stop); + } else { + yield* runtime.interruptTurn({ providerThread, providerTurnId: providerTurnId! }); + assert.isFalse(fake.allRequests().some((request) => request["type"] === "abort")); + } + yield* fake.closeStdout; + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); + assert.isFalse( + fake + .allRequests() + .slice(interruptedAt) + .some((request) => request["type"] === "prompt"), + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("delivers a steer sent during in-turn compaction once it ends", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + assert.isDefined(providerTurnId); + yield* fake.emit({ type: "agent_start" }); + fake.queueState({ isStreaming: false, isCompacting: true, pendingMessageCount: 0 }); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + fake.setCompacting(true); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + + yield* runtime.steerTurn({ + threadId: THREAD_ID, + runId: RunId.make("run:thread-pi-test:1"), + providerThread, + providerTurnId: providerTurnId!, + message: { + messageId: "message:thread-pi-test:steer-during-compaction" as never, + text: "Steer during compaction", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }); + fake.setCompacting(false); + const compactionEnded = fake.allRequests().length; + yield* fake.emit({ + type: "compaction_end", + reason: "manual", + result: { summary: "smaller", tokensBefore: 10_000, estimatedTokensAfter: 2_000 }, + aborted: false, + willRetry: false, + }); + const delivered = yield* fake.takeRequest("prompt"); + assert.equal(delivered["message"], "Steer during compaction"); + yield* fake.emit({ type: "agent_start" }); + fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.equal( + fake + .allRequests() + .slice(compactionEnded) + .filter( + (request) => + request["type"] === "prompt" && request["message"] === "Steer during compaction", + ).length, + 1, + ); + assert.isTrue( + terminal.type === "turn.terminal" && terminal.status === "completed", + `turn ended ${terminal.type === "turn.terminal" ? terminal.status : terminal.type}`, + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("keeps working after a settle probe fails before detached compaction", () => Effect.gen(function* () { const fake = yield* makeFakePi; @@ -4414,3 +4741,35 @@ describe("PiRpc early process exit", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); }); + +describe("PiRpc teardown", () => { + // Like pi, quits once stdin ends; it survives SIGTERM, so teardown's SIGKILL ends it otherwise. + const recordShutdownCues = ` +const fs = require("node:fs"); +const note = (cue) => fs.appendFileSync(process.argv[1], cue + "\\n"); +process.on("SIGTERM", () => note("SIGTERM")); +process.stdin.on("end", () => note("stdin end")).resume(); +process.stdout.write('{"type":"ready"}\\n'); +`; + + it.live.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "signals pi before its stdin reaches EOF", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cuesPath = `${yield* fileSystem.makeTempDirectoryScoped()}/cues`; + yield* Effect.gen(function* () { + const connection = yield* makePiRpcConnection({ + command: process.execPath, + args: ["-e", recordShutdownCues, cuesPath], + cwd: undefined, + env: {}, + }); + assert.equal((yield* Queue.take(connection.events))["type"], "ready"); + }).pipe(Effect.scoped); + + const cues = (yield* fileSystem.readFileString(cuesPath)).split("\n").filter(Boolean); + assert.deepEqual(cues, ["SIGTERM"]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index a32a5759ba707..4f3a6175aa39a 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -398,6 +398,15 @@ function piApprovalRequestKind(title: string): "command" | "file-change" { : "command"; } +/** Pi 1.4.0 rejects prompts with this when they race a compaction it already started. */ +const PI_COMPACTING_PROMPT_REJECTION = "Cannot submit a prompt while compaction is in progress"; + +interface PiPromptSend { + readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; + readonly kind: "turn_start" | "steer"; + readonly record: PiRpcRecord; +} + interface PiThreadState { providerThread: OrchestrationV2ProviderThread; activeTurn: ActivePiTurn | null; @@ -560,14 +569,30 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const modelContextWindows = new Map(); let modelsDiscovered = false; // Prompt ACKs lack IDs; retain session send order and ownership so late ACKs cannot affect new turns. - const pendingPromptResponses: Array<{ - readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; - readonly kind: "turn_start" | "steer"; - }> = []; + const pendingPromptResponses: Array = []; const pendingCompactResponses: Array<{ readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; readonly kind: "turn_start" | "steer"; }> = []; + // Pi rejects every prompt while it compacts, including compaction an + // extension starts after the turn settled. Hold prompts until it ends. + let nativeCompacting = false; + const heldPrompts: Array = []; + // Prompts sent before the pump observed compaction_start are rejected + // after it; they precede every prompt held since. + let rejectedHeldCount = 0; + + const sendPrompt = Effect.fnUntraced(function* (prompt: PiPromptSend) { + if (nativeCompacting) { + heldPrompts.push(prompt); + return; + } + yield* connection.send(prompt.record); + pendingPromptResponses.push(prompt); + }); + // A just-released prompt has not started its run yet, so a settle probe + // racing it would see Pi idle. Its ack re-arms the probe instead. + let releasedPromptTurnId: OrchestrationV2ProviderTurn["id"] | null = null; const compactRpcRecord = (command: PiCompactCommand): PiRpcRecord => command.customInstructions === undefined @@ -1835,7 +1860,10 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( if (result === null || result === undefined) { if (event["aborted"] === true) { yield* emitCompaction(turn, compaction, "cancelled"); - if (turn.settleWhenIdle || !turn.sawAgentActivity) { + if ( + (turn.settleWhenIdle || !turn.sawAgentActivity) && + releasedPromptTurnId !== turn.providerTurn.id + ) { yield* scheduleSettleProbe(turn, turn.settleWhenIdle); } return; @@ -1845,7 +1873,10 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( yield* emitCompaction(turn, compaction, "failed", { summary: errorMessage.slice(0, 1_000), }); - if (turn.settleWhenIdle || !turn.sawAgentActivity) { + if ( + (turn.settleWhenIdle || !turn.sawAgentActivity) && + releasedPromptTurnId !== turn.providerTurn.id + ) { yield* scheduleSettleProbe(turn, turn.settleWhenIdle); } return; @@ -1862,7 +1893,10 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ...(beforeTokenCount === undefined ? {} : { beforeTokenCount }), ...(afterTokenCount === undefined ? {} : { afterTokenCount }), }); - if (turn.settleWhenIdle || !turn.sawAgentActivity) { + if ( + (turn.settleWhenIdle || !turn.sawAgentActivity) && + releasedPromptTurnId !== turn.providerTurn.id + ) { yield* scheduleSettleProbe(turn, turn.settleWhenIdle); } return; @@ -2012,6 +2046,22 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const pendingPrompt = command === "prompt" ? pendingPromptResponses.shift() : undefined; const responseTurn = pendingPrompt?.providerTurnId === turn?.providerTurn.id ? turn : null; + if ( + event["success"] === false && + nativeCompacting && + recordString(event, "error")?.startsWith(PI_COMPACTING_PROMPT_REJECTION) === true && + pendingPrompt !== undefined && + responseTurn !== null + ) { + heldPrompts.splice(rejectedHeldCount++, 0, pendingPrompt); + return; + } + // Settlement is not probed while a steer awaits its ack (see + // compaction_end); once Pi answers, the run it may have started is + // visible to get_state. + if (pendingPrompt?.kind === "steer" && responseTurn?.settleWhenIdle === true) { + yield* scheduleSettleProbe(responseTurn, true); + } if (event["success"] === true) { // Command-only prompts emit no agent_settled; probe idle and re-queue behind preceding native events. if ( @@ -2123,7 +2173,33 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( yield* Effect.gen(function* () { while (true) { const event = yield* Queue.take(connection.events); - yield* sessionEventPermit.withPermits(1)(handleSessionEvent(event)); + yield* sessionEventPermit.withPermits(1)( + Effect.gen(function* () { + // Tracked here rather than in handleSessionEvent, which also + // replays buffered wake events after they were observed. + if (event["type"] === "compaction_start") nativeCompacting = true; + if (event["type"] === "compaction_end") { + nativeCompacting = false; + // Send before the handler can probe settlement, so Pi sees the + // prompt first. Prompts whose turn ended or is stopping are dropped. + rejectedHeldCount = 0; + for (const held of heldPrompts.splice(0)) { + const turn = threadState?.activeTurn; + if ( + turn?.providerTurn.id !== held.providerTurnId || + turn.interrupted || + stopRequested + ) + continue; + yield* sendPrompt(held); + releasedPromptTurnId = held.providerTurnId; + } + } + yield* handleSessionEvent(event).pipe( + Effect.ensuring(Effect.sync(() => (releasedPromptTurnId = null))), + ); + }), + ); } }).pipe( Effect.catchCause((cause) => @@ -2558,15 +2634,15 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( kind: "turn_start", }); } else if (payload !== null) { - yield* connection.send({ - type: "prompt", - message: payload.message, - streamingBehavior: "steer", - ...(payload.images.length === 0 ? {} : { images: payload.images }), - }); - pendingPromptResponses.push({ + yield* sendPrompt({ providerTurnId: providerTurn.id, kind: "turn_start", + record: { + type: "prompt", + message: payload.message, + streamingBehavior: "steer", + ...(payload.images.length === 0 ? {} : { images: payload.images }), + }, }); } yield* emit({ @@ -2652,15 +2728,15 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( kind: "steer", }); } else if (payload !== null) { - yield* connection.send({ - type: "prompt", - message: payload.message, - streamingBehavior: "steer", - ...(payload.images.length === 0 ? {} : { images: payload.images }), - }); - pendingPromptResponses.push({ + yield* sendPrompt({ providerTurnId: turn.providerTurn.id, kind: "steer", + record: { + type: "prompt", + message: payload.message, + streamingBehavior: "steer", + ...(payload.images.length === 0 ? {} : { images: payload.images }), + }, }); } turn.settleProbeGeneration += 1; @@ -2696,7 +2772,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( interruptInput.requestRuntimeRestart === true || turn.settleWhenIdle || turn.activeCompaction !== null || - turn.manualCompactInFlight + turn.manualCompactInFlight || + nativeCompacting ) { // Generic abort misses manual compaction; terminate so Stop covers /compact and recovery. stopRequested = true; @@ -3026,6 +3103,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( environment: options.environment, mcpSession: undefined, extensionPath: undefined, + shortLived: true, disableExtensions: true, disableTools: true, }); @@ -3298,8 +3376,10 @@ function piSubagentOutput(result: unknown): string { function piExtensionDisplayName(extensionPath: string | undefined): string { if (extensionPath === undefined) return "Pi extension"; - const normalized = extensionPath.replace(/\\/g, "/").replace(/\/+$/, ""); - const name = normalized.slice(normalized.lastIndexOf("/") + 1).replace(/\.[^.]+$/, ""); + const segments = extensionPath.replace(/\\/g, "/").split("/").filter(Boolean); + const fileName = segments.at(-1)?.replace(/\.[^.]+$/, "") ?? ""; + // Pi loads a directory extension from its index file; the folder names it. + const name = fileName === "index" ? (segments.at(-2) ?? fileName) : fileName; return name.length === 0 ? "Pi extension" : name; } diff --git a/packages/provider-pi/src/server/driver.test.ts b/packages/provider-pi/src/server/driver.test.ts index 087a8089aaee8..59586fdaedb39 100644 --- a/packages/provider-pi/src/server/driver.test.ts +++ b/packages/provider-pi/src/server/driver.test.ts @@ -169,6 +169,17 @@ it.layer(layerTest)("PiDriver workspace discovery", (it) => { ["--approve", "--skill", "extra skill", "--no-session"], ); assert.equal(workspaceLaunch!.options.env?.PI_CODING_AGENT_DIR, "/isolated-pi"); + // Discovery processes are stopped as soon as they answer, so neither may start Pi's + // network refresh at launch. + for (const cwd of ["/machine", "/first"]) { + const discovery = launches.find( + (launch) => launch.options.cwd === cwd && launch.args.includes("--mode"), + ); + assert.deepEqual( + discovery?.args.filter((arg) => arg === "--offline"), + ["--offline"], + ); + } }).pipe(Effect.scoped), ); diff --git a/packages/provider-pi/src/server/mcpInjection.test.ts b/packages/provider-pi/src/server/mcpInjection.test.ts index 35380d7227d30..1dabaa6940bae 100644 --- a/packages/provider-pi/src/server/mcpInjection.test.ts +++ b/packages/provider-pi/src/server/mcpInjection.test.ts @@ -130,6 +130,7 @@ describe("pi T3 MCP injection", () => { extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", sessionPath: "/fake/persisted.jsonl", ephemeral: true, + shortLived: true, disableExtensions: true, disableTools: true, }); @@ -139,9 +140,19 @@ describe("pi T3 MCP injection", () => { "--no-session", "--provider", "anthropic", + "--offline", "--no-extensions", "--no-tools", ]); + const alreadyOffline = buildPiRpcLaunch({ + launchArgs: ["--offline"], + environment: {}, + mcpSession: undefined, + extensionPath: undefined, + ephemeral: true, + shortLived: true, + }); + assert.deepEqual(alreadyOffline.args, ["--mode", "rpc", "--no-session", "--offline"]); assert.deepInclude(resolvePiLaunchArgs("--mode text"), { ok: false, message: "Pi launch argument '--mode' is controlled by T3 Code and cannot be overridden.", diff --git a/packages/provider-pi/src/server/mcpInjection.ts b/packages/provider-pi/src/server/mcpInjection.ts index a58a699037d49..0936582cade09 100644 --- a/packages/provider-pi/src/server/mcpInjection.ts +++ b/packages/provider-pi/src/server/mcpInjection.ts @@ -246,6 +246,9 @@ export function buildPiRpcLaunch(input: { readonly extensionPath: string | undefined; readonly sessionPath?: string | undefined; readonly ephemeral?: boolean; + // T3 stops these processes as soon as they answer. They run --offline because Pi's startup + // catalog refresh renews expired OAuth tokens, and a stop mid-request loses the rotated one. + readonly shortLived?: boolean; readonly disableExtensions?: boolean; readonly disableTools?: boolean; readonly runtimeMode?: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; @@ -270,6 +273,7 @@ export function buildPiRpcLaunch(input: { ? ["--session", input.sessionPath] : []), ...launchArgs, + ...(input.shortLived === true && !launchArgs.includes("--offline") ? ["--offline"] : []), // Restrictions must follow launch args so user flags cannot re-enable unattended tools/extensions. ...(input.disableExtensions === true ? ["--no-extensions"] : []), ...(input.disableTools === true ? ["--no-tools"] : []), diff --git a/packages/provider-pi/src/server/rpc.ts b/packages/provider-pi/src/server/rpc.ts index 0af3fade61c33..98eacd790139e 100644 --- a/packages/provider-pi/src/server/rpc.ts +++ b/packages/provider-pi/src/server/rpc.ts @@ -199,6 +199,8 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp extendEnv: false, shell: spawnCommand.shell, detached: platform !== "win32", + // On stdin EOF Pi quits without its SIGTERM handler, so the later SIGTERM kills it mid-cleanup. + stdin: { stream: "pipe", endOnDone: false }, }), ) .pipe(Effect.mapError((cause) => new PiRpcError({ operation: "spawn", cause }))); diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index 1f3a91da384e6..fa4deb8235467 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -149,6 +149,7 @@ const makePiDiscoveryConnection = Effect.fnUntraced(function* ( mcpSession: undefined, extensionPath: undefined, ephemeral: true, + shortLived: true, }); const connection = yield* makePiRpcConnection({ command: piSettings.binaryPath || "pi", diff --git a/packages/provider-pi/src/server/textGeneration.test.ts b/packages/provider-pi/src/server/textGeneration.test.ts index ba693da18012b..0e6a8a3c07780 100644 --- a/packages/provider-pi/src/server/textGeneration.test.ts +++ b/packages/provider-pi/src/server/textGeneration.test.ts @@ -7,6 +7,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { makePiTextGeneration } from "./textGeneration.ts"; @@ -68,6 +69,35 @@ const makeFakePi = ( return { spawner, received }; }); +it.effect("runs Pi offline, since the process is stopped as soon as it answers", () => + Effect.gen(function* () { + const pi = yield* makeFakePi(JSON.stringify({ subject: "Update README", body: "" })); + const launches: Array> = []; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) launches.push(command.args.map(String)); + return pi.spawner.spawn(command); + }); + const textGeneration = yield* makePiTextGeneration( + { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] }, + {}, + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + + const result = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "main", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: createModelSelection(ProviderInstanceId.make("pi"), "default"), + }); + + assert.equal(result.subject, "Update README"); + assert.deepEqual( + launches.map((args) => args.filter((arg) => arg === "--offline")), + [["--offline"]], + ); + }), +); + it.effect("names the model and the disabled extensions when Pi cannot load it", () => Effect.gen(function* () { const pi = yield* makeFakePi("", "Model not found: demo/demo-model"); diff --git a/packages/provider-pi/src/server/textGeneration.ts b/packages/provider-pi/src/server/textGeneration.ts index f52ec08deab35..0601a66815bf0 100644 --- a/packages/provider-pi/src/server/textGeneration.ts +++ b/packages/provider-pi/src/server/textGeneration.ts @@ -47,6 +47,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* mcpSession: undefined, extensionPath: undefined, ephemeral: true, + shortLived: true, // Coder: keep the workspace's Pi extensions, which may register the only model providers. disableExtensions: false, // Background naming/content helpers must never mutate the workspace. diff --git a/packages/provider-testing/package.json b/packages/provider-testing/package.json index 0477cbef634e9..1f0e3e02fd922 100644 --- a/packages/provider-testing/package.json +++ b/packages/provider-testing/package.json @@ -25,7 +25,7 @@ } }, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "effect-tsgo tsrs --noEmit" }, "dependencies": { "@effect/platform-node": "catalog:", diff --git a/packages/provider-testing/src/fakeCli.ts b/packages/provider-testing/src/fakeCli.ts index 2213d361026e6..777f20a9f6476 100644 --- a/packages/provider-testing/src/fakeCli.ts +++ b/packages/provider-testing/src/fakeCli.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - synchronous fixture writer used from plain and Effect tests alike. +// @effect-diagnostics nodeBuiltinImport:off globalJsonInEffect:off - synchronous fixture writer used from plain and Effect tests alike. import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; diff --git a/packages/shared/package.json b/packages/shared/package.json index b79a857305bd3..51156ddac620e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -200,6 +200,10 @@ "types": "./src/delimitedPreview.ts", "import": "./src/delimitedPreview.ts" }, + "./socketFrameParts": { + "types": "./src/socketFrameParts.ts", + "import": "./src/socketFrameParts.ts" + }, "./thirdPartyLicenses": { "types": "./src/thirdPartyLicenses.ts", "import": "./src/thirdPartyLicenses.ts" @@ -328,6 +332,10 @@ "types": "./src/markdownFrontmatter.ts", "import": "./src/markdownFrontmatter.ts" }, + "./markdownTaskList": { + "types": "./src/markdownTaskList.ts", + "import": "./src/markdownTaskList.ts" + }, "./markdownListIndentation": { "types": "./src/markdownListIndentation.ts", "import": "./src/markdownListIndentation.ts" @@ -394,7 +402,7 @@ } }, "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "effect-tsgo tsrs --noEmit", "test": "vp test run" }, "dependencies": { diff --git a/packages/shared/src/HostProcess.ts b/packages/shared/src/HostProcess.ts index f24365cd5c81f..50f0a3ef85950 100644 --- a/packages/shared/src/HostProcess.ts +++ b/packages/shared/src/HostProcess.ts @@ -1,5 +1,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as NodeDns from "node:dns"; import * as NodeOS from "node:os"; import * as NodeSea from "node:sea"; @@ -73,6 +74,15 @@ export const IsExecutable = Context.Reference("@t3tools/shared/HostProc defaultValue: () => NodeSea.isSea(), }); +export class HostnameLookupError extends Schema.TaggedError()( + "HostnameLookupError", + { hostname: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not resolve ${this.hostname}.`; + } +} + /** Includes hostname-resolved addresses absent from interfaces, such as Debian's 127.0.1.1 used by Firefox locks. */ export const Addresses = Context.Reference>>( "@t3tools/shared/HostProcess/Addresses", @@ -82,9 +92,11 @@ export const Addresses = Context.Reference>>( const interfaces = Object.values(NodeOS.networkInterfaces()) .flat() .flatMap((entry) => (entry ? [entry.address] : [])); - const resolved = yield* Effect.tryPromise(() => - NodeDns.promises.lookup(NodeOS.hostname(), { all: true }), - ).pipe( + const hostname = NodeOS.hostname(); + const resolved = yield* Effect.tryPromise({ + try: () => NodeDns.promises.lookup(hostname, { all: true }), + catch: (cause) => new HostnameLookupError({ hostname, cause }), + }).pipe( Effect.map((entries) => entries.map((entry) => entry.address)), Effect.orElseSucceed(() => [] as ReadonlyArray), ); diff --git a/packages/shared/src/cloneUrl.test.ts b/packages/shared/src/cloneUrl.test.ts index 16503923d8aa3..50ab17f91141f 100644 --- a/packages/shared/src/cloneUrl.test.ts +++ b/packages/shared/src/cloneUrl.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { cloneUrlForProtocol, cloneUrlProtocol } from "./cloneUrl.ts"; +import { cloneUrlForProtocol, cloneUrlProtocol, cloneUrlRepositoryPath } from "./cloneUrl.ts"; describe("cloneUrlForProtocol", () => { it.each([ @@ -138,3 +138,29 @@ describe("cloneUrlForProtocol", () => { expect(cloneUrlProtocol("file:///srv/git/repo.git")).toBeNull(); }); }); + +describe("cloneUrlRepositoryPath", () => { + it("rejects Windows device names and trailing dots or spaces in every folder", () => { + for (const segment of [ + "CON", + "nul.txt", + "COM1", + "LPT0", + "com¹", + "CONIN$", + "conout$", + "aux .txt", + "repo.", + "repo ", + ]) { + expect(cloneUrlRepositoryPath(`https://github.com/${segment}/repo.git`)).toBeNull(); + expect(cloneUrlRepositoryPath(`https://${segment}/owner/repo.git`)).toBeNull(); + expect(cloneUrlRepositoryPath(`https://github.com/owner/${segment}.git`)).toBeNull(); + } + expect(cloneUrlRepositoryPath("https://github.com/acme/repo.git")).toBe("github.com/acme/repo"); + expect(cloneUrlRepositoryPath("ssh://git@gitlab.com:2222/group/sub/repo.git")).toBe( + "gitlab.com/group/sub/repo", + ); + expect(cloneUrlRepositoryPath("C:/repos/project.git")).toBeNull(); + }); +}); diff --git a/packages/shared/src/cloneUrl.ts b/packages/shared/src/cloneUrl.ts index cface92a89a3c..597f21d1d1f1f 100644 --- a/packages/shared/src/cloneUrl.ts +++ b/packages/shared/src/cloneUrl.ts @@ -1,3 +1,5 @@ +import { isWindowsDrivePath } from "./path.ts"; + export type CloneProtocol = "ssh" | "https"; const SCP_LIKE_URL = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/)(.+)$/; @@ -46,3 +48,26 @@ export function cloneUrlForProtocol(url: string, protocol: CloneProtocol): strin ? `git@${parsed.host}:${parsed.path}` : `https://${parsed.host}/${parsed.path}`; } + +const UNSAFE_SEGMENT = /^\.{1,2}$|[\\:*?"<>|\p{Cc}]|[ .]$/u; +const WINDOWS_DEVICE_NAME = + /^(?:con|prn|aux|nul|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])(?: *\.| *$)/i; + +/** + * The `//` folders ghq clones a URL into, or null for local paths and + * anything with a folder that could leave the root. + */ +export function cloneUrlRepositoryPath(url: string): string | null { + if (isWindowsDrivePath(url.trim())) return null; + const parsed = parseCloneUrl(url); + if (!parsed) return null; + const segments = [parsed.host, ...parsed.path.split(/[?#]/)[0]!.split("/").filter(Boolean)]; + const last = segments.at(-1)!; + if (last.endsWith(".git")) segments[segments.length - 1] = last.slice(0, -".git".length); + if ( + segments.length < 2 || + segments.some((s) => s === "" || UNSAFE_SEGMENT.test(s) || WINDOWS_DEVICE_NAME.test(s)) + ) + return null; + return segments.join("/"); +} diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index df3119dc9248a..4042f4cc6002d 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -17,6 +17,7 @@ import { stripAttachmentOnlyBootstrapPrompt, threadContextRecord, threadContextsOutsideEnvironment, + threadLinkContextReferences, toComposerContextId, toKindScopedComposerContextId, } from "./composerContextReferences.ts"; @@ -59,6 +60,10 @@ describe("labels and reference links", () => { collectComposerContextReferences(`[${"x".repeat(300)}](t3-context://v1/file/ctx_1)`)[0] ?.label, ).toHaveLength(200); + expect( + collectComposerContextReferences(`[${"x".repeat(199)}😀tail](t3-context://v1/file/ctx_1)`)[0] + ?.label, + ).toBe("x".repeat(199)); }); it("collects occurrences in document order with offsets, sharing a payload", () => { @@ -221,6 +226,29 @@ describe("provider projection", () => { expect(project("a\n")).toContain("3 | a\n4 | \n"); }); + it("keeps a review excerpt's trailing blank line and whitespace", () => { + const projected = projectComposerContextForProvider({ + text: "[Comment](t3-context://v1/review-comment/ctx_r)", + records: [ + { + version: 1, + contextId: ctx("ctx_r"), + kind: "review-comment", + label: "Comment", + sectionId: "turn:1", + sectionTitle: "Turn 1", + filePath: "src/app.ts", + startIndex: 1, + endIndex: 2, + rangeLabel: "2 to 3", + text: "Insert here.", + diff: "@@ -3,1 +2,2 @@\n+two\t\n \n", + }, + ], + }); + expect(projected).toContain("diff:\n @@ -3,1 +2,2 @@\n +two\t\n \n"); + }); + it("emits no terminal lines when the captured range is inverted", () => { const projected = projectComposerContextForProvider({ text: "[log](t3-context://v1/terminal/ctx_t)", @@ -421,3 +449,65 @@ describe("threadContextRecord", () => { ).toBe("thread_abc-123"); }); }); + +describe("threadLinkContextReferences", () => { + const thread = (environmentId: string, threadId: string) => ({ + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(threadId), + }); + const reference = (environmentId: string, threadId: string, label: string) => + `[${label}](t3-context://v1/thread/${threadContextRecord(thread(environmentId, threadId), label).contextId})`; + + it("makes a chip of a copied link, a v2 link, and a labeled link, titled here", () => { + const pasted = threadLinkContextReferences( + [ + "https://app.t3.codes/threads/env-1/t1", + "", + "[Written](https://app.t3.codes/threads/env-2/t3)", + ].join(" "), + [], + ({ threadId }) => (threadId === "t3" ? undefined : `Title ${threadId}`), + ); + expect(pasted.text).toBe( + [ + reference("env-1", "t1", "Title t1"), + reference("env-1", "t2", "Title t2"), + reference("env-2", "t3", "Written"), + ].join(" "), + ); + expect(pasted.records).toEqual([ + threadContextRecord(thread("env-1", "t1"), "Title t1"), + threadContextRecord(thread("env-1", "t2"), "Title t2"), + threadContextRecord(thread("env-2", "t3"), "Written"), + ]); + }); + + it("prefers the record the copy carried, and names an unknown thread plainly", () => { + const copied = threadContextRecord(thread("env-1", "t1"), "Copied title"); + const pasted = threadLinkContextReferences( + "https://app.t3.codes/threads/env-1/t1 https://app.t3.codes/threads/env-9/t9", + [copied], + () => "Here", + ); + expect(pasted.records).toEqual([copied, threadContextRecord(thread("env-9", "t9"), "Here")]); + expect( + threadLinkContextReferences("https://app.t3.codes/threads/env-9/t9", [], () => undefined) + .records, + ).toEqual([threadContextRecord(thread("env-9", "t9"), "Thread")]); + }); + + it("keeps links it may not chip, v1 links, and links in code as written", () => { + const text = [ + "https://app.t3.codes/threads/elsewhere/t1", + "[Old](t3-thread://v1/t2)", + "`https://app.t3.codes/threads/env-1/t3`", + ].join(" "); + const pasted = threadLinkContextReferences( + text, + [], + () => "Title", + () => undefined, + ); + expect(pasted).toEqual({ text, records: [] }); + }); +}); diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index 18b12328515a1..22aa6b320d77c 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -10,6 +10,8 @@ import { type ThreadContextRecord, } from "@t3tools/contracts"; +import { replaceThreadLinks, threadLinkCandidates } from "./threadLinks.ts"; + const CONTEXT_PROTOCOL = "t3-context:"; const COMPOSER_CONTEXT_HREF_PREFIX = `${CONTEXT_PROTOCOL}//v1/`; const CONTEXT_KIND_PATTERN = /^[a-z][a-z0-9-]{0,39}$/; @@ -67,11 +69,15 @@ export function parseComposerContextHref( } export function sanitizeComposerContextLabel(label: string, kind: ComposerContextKind): string { - const cleaned = label + const normalized = label .replace(/[[\]\\\r\n]/g, " ") .replace(/\s+/g, " ") - .trim() - .slice(0, COMPOSER_CONTEXT_LABEL_MAX_CHARS); + .trim(); + let end = COMPOSER_CONTEXT_LABEL_MAX_CHARS; + const before = normalized.charCodeAt(end - 1); + const after = normalized.charCodeAt(end); + if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) end--; + const cleaned = normalized.slice(0, end); return cleaned.length > 0 ? cleaned : kind; } @@ -89,6 +95,45 @@ export function threadContextRecord(ref: ScopedThreadRef, title: string): Thread }; } +/** + * Turns pasted thread links into thread chip references. A record the copy carried names its + * chip, else the thread's title here, read by key, or the link's own label. `accept` gives the + * chip's id, or none to keep the link as written. A v1 link names no machine, so it stays a link. + */ +export function threadLinkContextReferences( + text: string, + copied: ReadonlyArray, + title: (ref: ScopedThreadRef) => string | undefined, + accept: (record: ThreadContextRecord) => ComposerContextId | undefined = (record) => + record.contextId, +): { readonly text: string; readonly records: ReadonlyArray } { + const records = new Map(); + const rewritten = replaceThreadLinks(text, (link, label) => { + if (link.environmentId === null) return undefined; + const candidates = threadLinkCandidates(link, link.environmentId); + const record = + copied.find( + (candidate): candidate is ThreadContextRecord => + candidate.kind === "thread" && + "threadId" in candidate && + candidates.some( + (ref) => + ref.environmentId === candidate.environmentId && ref.threadId === candidate.threadId, + ), + ) ?? + candidates.flatMap((ref) => { + const known = title(ref); + return known === undefined ? [] : [threadContextRecord(ref, known)]; + })[0] ?? + threadContextRecord(candidates[0]!, label?.trim() || "Thread"); + const contextId = accept(record); + if (contextId === undefined) return undefined; + records.set(record.contextId, record); + return formatComposerContextReference({ ...record, contextId }); + }); + return { text: rewritten, records: [...records.values()] }; +} + export function formatComposerContextReference(reference: { kind: ComposerContextKind; contextId: ComposerContextId; @@ -289,7 +334,7 @@ function formatComposerContextProviderPayload(record: KnownComposerContextRecord ]; if (record.text.trim()) lines.push("comment:", indent(record.text.trim())); if (record.diff.trim()) { - lines.push(`${record.fenceLanguage ?? "diff"}:`, indent(record.diff.trimEnd())); + lines.push(`${record.fenceLanguage ?? "diff"}:`, indent(record.diff.replace(/\n+$/, ""))); } return lines.join("\n"); } diff --git a/packages/shared/src/filePreview.test.ts b/packages/shared/src/filePreview.test.ts index 9024954ebf1ad..d23a82bc2042e 100644 --- a/packages/shared/src/filePreview.test.ts +++ b/packages/shared/src/filePreview.test.ts @@ -24,7 +24,16 @@ describe("workspace file previews", () => { ["db", "sqlite", "application/vnd.sqlite3"], ])("recognizes binary preview %s", (extension, kind, mimeType) => { const name = `files#draft/sample.${extension.toUpperCase()}`; - expect(filePreviewKind({ name, mimeType: "application/octet-stream" })).toBe(kind); + for (const genericMime of [ + "", + "application/octet-stream", + "binary/octet-stream", + "application/unknown", + " APPLICATION/UNKNOWN ; charset=utf-8", + "text/plain", + ]) { + expect(filePreviewKind({ name, mimeType: genericMime })).toBe(kind); + } expect(filePreviewKind({ name: "download", mimeType })).toBe(kind); expect(isWorkspacePreviewEntryPath(name)).toBe(true); expect(isWorkspacePreviewEntryPath(`${name}?download=1`)).toBe(false); @@ -79,7 +88,16 @@ describe("workspace file previews", () => { ["analysis.ipynb", "ipynb", "application/x-ipynb+json"], ])("recognizes Office assets at literal path %s", (name, kind, mimeType) => { expect(officePreviewKind({ name })).toBe(kind); - expect(filePreviewKind({ name, mimeType: "application/octet-stream" })).toBe(kind); + for (const genericMime of [ + "", + "application/octet-stream", + "binary/octet-stream", + "application/unknown", + " APPLICATION/UNKNOWN ; charset=utf-8", + "text/plain", + ]) { + expect(filePreviewKind({ name, mimeType: genericMime })).toBe(kind); + } expect(isWorkspacePreviewEntryPath(name)).toBe(true); expect(hostPreviewMimeTypeFromExtension(`.${kind}`)).toBe(mimeType); expect(filePreviewKind({ name: "download", mimeType })).toBe(kind); diff --git a/packages/shared/src/filePreview.ts b/packages/shared/src/filePreview.ts index 2cd77e2e59779..ef8d6f2d0cff2 100644 --- a/packages/shared/src/filePreview.ts +++ b/packages/shared/src/filePreview.ts @@ -126,7 +126,7 @@ export function binaryPreviewKind(file: { ].includes(mime) ) return "archive"; - if (mime && mime !== "application/octet-stream" && mime !== "text/plain") return null; + if (mime && !GENERIC_MIME_TYPES.has(mime) && mime !== "text/plain") return null; const extension = file.name.slice(file.name.lastIndexOf(".")).toLowerCase(); const inferred = BINARY_MIME_TYPES.get(extension); return inferred === "application/vnd.sqlite3" ? "sqlite" : inferred ? "archive" : null; @@ -158,7 +158,7 @@ export function officePreviewKind(file: { if (mime === OFFICE_MIME_TYPES[kind]) return kind; if ( (!mime || - mime === "application/octet-stream" || + GENERIC_MIME_TYPES.has(mime) || mime === "text/plain" || (kind === "ipynb" && mime === "application/json")) && file.name.toLowerCase().endsWith(`.${kind}`) diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index ede9f059c578c..a788abb1d1d7e 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -7,6 +7,7 @@ import { formatGeneratedBranchName, buildTemporaryWorktreeBranchName, flattenTemporaryWorktreeBranchName, + isBareGitConfig, isTemporaryWorktreeBranch, normalizeGitRemoteUrl, parseOriginUrlFromGitConfig, @@ -64,6 +65,15 @@ describe("normalizeGitRemoteUrl", () => { }); }); +describe("isBareGitConfig", () => { + it("reads continued bare values after comments and at end of input", () => { + const config = ["[core] bare = false # ignored backslash \\", 'bare = "tr\\', 'ue"'].join("\n"); + + expect(isBareGitConfig(config)).toBe(true); + expect(isBareGitConfig("[core]\nbare = tr\\\nue\\")).toBe(true); + }); +}); + describe("parseOriginUrlFromGitConfig", () => { it("reads the origin url and ignores other remotes", () => { const config = [ diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 4ac89394c213f..ba7c9f0a97b70 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -170,6 +170,29 @@ export function normalizeGitRemoteUrl(value: string): string { return repository === null ? lowerCaseRemote(value) : `${repository.host}/${repository.path}`; } +function* gitConfigLines(configText: string) { + let pending = ""; + let quoted = false; + for (const line of configText.split(/\r?\n/)) { + let continued = false; + if (line.endsWith("\\")) { + for (const [token] of line.matchAll(/\\[\s\S]?|["#;]/g)) { + if (token === '"') quoted = !quoted; + else if (!quoted && (token === "#" || token === ";")) break; + else if (token === "\\") continued = true; + } + } + if (continued) { + pending += line.slice(0, -1); + continue; + } + yield pending + line; + pending = ""; + quoted = false; + } + if (pending.length > 0) yield pending; +} + function parseGitConfigValue(raw: string): string { let out = ""; let quoted = false; @@ -193,7 +216,7 @@ function parseGitConfigValue(raw: string): string { export function isBareGitConfig(configText: string): boolean { let inCore = false; let bare = false; - for (const rawLine of configText.split(/\r?\n/)) { + for (const rawLine of gitConfigLines(configText)) { let line = rawLine.trim(); // Git accepts the first setting on the section header's own line. const header = /^\[\s*([^\]]*?)\s*\](.*)$/.exec(line); diff --git a/packages/shared/src/inlineSkills.test.ts b/packages/shared/src/inlineSkills.test.ts index 9413148cee9e7..dde40480cab01 100644 --- a/packages/shared/src/inlineSkills.test.ts +++ b/packages/shared/src/inlineSkills.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatProviderSkillDisplayName } from "./inlineSkills.ts"; +import { formatProviderSkillDisplayName, matchInlineSkills } from "./inlineSkills.ts"; describe("formatProviderSkillDisplayName", () => { it.each([ @@ -17,3 +17,26 @@ describe("formatProviderSkillDisplayName", () => { ); }); }); + +describe("matchInlineSkills", () => { + it("preserves offsets, skill identity, first-name precedence, and unknown tokens", () => { + const first = { name: "design", displayName: "Design" }; + const duplicate = { name: "design", displayName: "Other design" }; + const namespaced = { name: "pdf:pdf" }; + const matches = [ + ...matchInlineSkills("Use $design then $missing and $pdf:pdf, $pdf:pdf $design", [ + first, + duplicate, + namespaced, + ]), + ]; + expect(matches).toEqual([ + { start: 4, end: 11, skill: first, rawText: "$design" }, + { start: 40, end: 48, skill: namespaced, rawText: "$pdf:pdf" }, + { start: 49, end: 56, skill: first, rawText: "$design" }, + ]); + expect(matches[0]?.skill).toBe(first); + expect(matches[1]?.skill).toBe(namespaced); + expect(matches[2]?.skill).toBe(first); + }); +}); diff --git a/packages/shared/src/inlineSkills.ts b/packages/shared/src/inlineSkills.ts index 7a8c05f4e2b5b..aacbc38cf65d2 100644 --- a/packages/shared/src/inlineSkills.ts +++ b/packages/shared/src/inlineSkills.ts @@ -15,9 +15,16 @@ export function* matchInlineSkills( text: string, skills: readonly Skill[], ) { + const byName = new Map(); for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { const name = match[2] ?? ""; - const skill = skills.find((candidate) => candidate.name === name); + if (!byName.has(name)) { + byName.set( + name, + skills.find((candidate) => candidate.name === name), + ); + } + const skill = byName.get(name); if (!skill) continue; const start = match.index + (match[1]?.length ?? 0); yield { start, end: match.index + match[0].length, skill, rawText: `$${name}` }; diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts index 5337700871637..318f2e1c3333d 100644 --- a/packages/shared/src/markdownLinks.test.ts +++ b/packages/shared/src/markdownLinks.test.ts @@ -1,5 +1,6 @@ import { workspaceRelativeFilePath } from "./path.ts"; import { describe, expect, it } from "vite-plus/test"; +import { serializeComposerFileLink } from "./composerTrigger.ts"; import { extractMarkdownLinkHrefs, resolveMarkdownFileLinkTarget } from "./markdownLinks.ts"; import { @@ -38,16 +39,38 @@ describe("parseMarkdownFileLink", () => { ["C:%5Crepo%5Cimage.png", "C:\\repo\\image.png"], ["", "D:/Programme/t3code/ChatMarkdown.tsx"], ["file://localhost/home/me/notes.md", "/home/me/notes.md"], + ["app/routes/posts.$postId.tsx", "app/routes/posts.$postId.tsx"], + ["src/routes/+page.svelte", "src/routes/+page.svelte"], + ["app/(marketing)/page.tsx:12", "app/(marketing)/page.tsx"], + ["pages/posts/%5Bid%5D.tsx", "pages/posts/[id].tsx"], + ["+page.svelte", "+page.svelte"], + ["[id].tsx", "[id].tsx"], ])("recognizes %s as a file", (href, path) => { expect(parseMarkdownFileLink(href)?.path).toBe(path); }); - it.each(["https://example.com/docs", "/chat/settings#L3", "TODO:12", "patches"])( - "does not treat %s as a file", - (href) => { - expect(parseMarkdownFileLink(href)).toBeNull(); - }, - ); + it.each([ + "app/routes/posts.$postId.tsx", + "pages/posts/[id].tsx", + "docs/My File (draft).md", + "@scope/package.json", + "$postId.tsx", + ])("reads back the composer's link to %s", (path) => { + const destination = /\]\((.*)\)$/.exec(serializeComposerFileLink(path))?.[1]; + expect(parseMarkdownFileLink(destination ?? "")?.path).toBe(path); + }); + + it.each([ + "https://example.com/docs", + "/chat/settings#L3", + "TODO:12", + "patches", + "me@example.com", + "foo(bar)", + "$HOME", + ])("does not treat %s as a file", (href) => { + expect(parseMarkdownFileLink(href)).toBeNull(); + }); it("recognizes a relative folder that ends in a slash", () => { expect(parseMarkdownFileLink("patches/")).toEqual({ path: "patches/" }); diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index 177e6fcbf5008..6b76b61475ad5 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -14,14 +14,15 @@ import { const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; -// Names may use any script (`線性代數/考試.pdf`). The final segment is optional so an -// explicit folder link may end in `/` (`patches/`, `docs/My Folder/`). A bare name with no -// slash still fails. +// Names may use any script (`線性代數/考試.pdf`) and routing punctuation (`posts.$id.tsx`, +// `(group)/[id]/+page.svelte`, `@scope/pkg`). The final segment is optional so an explicit +// folder link may end in `/` (`patches/`, `docs/My Folder/`). A bare name with no slash still fails. const RELATIVE_FILE_PATH_PATTERN = - /^(?:[\p{L}\p{M}\p{N}._-]+(?: +[\p{L}\p{M}\p{N}._-]+)*\/)+(?:[\p{L}\p{M}\p{N}._-]+(?: +[\p{L}\p{M}\p{N}._-]+)*(?::\d+){0,2})?$/u; + /^(?:[\p{L}\p{M}\p{N}._$+@()[\]-]+(?: +[\p{L}\p{M}\p{N}._$+@()[\]-]+)*\/)+(?:[\p{L}\p{M}\p{N}._$+@()[\]-]+(?: +[\p{L}\p{M}\p{N}._$+@()[\]-]+)*(?::\d+){0,2})?$/u; +// No `@` here: `me@example.com` is an address, not a file. const RELATIVE_FILE_NAME_PATTERN = - /^[\p{L}\p{M}\p{N}._-]+(?: +[\p{L}\p{M}\p{N}._-]+)*\.[\p{L}\p{M}\p{N}_-]+(?::\d+){0,2}$/u; + /^[\p{L}\p{M}\p{N}._$+()[\]-]+(?: +[\p{L}\p{M}\p{N}._$+()[\]-]+)*\.[\p{L}\p{M}\p{N}_-]+(?::\d+){0,2}$/u; const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; diff --git a/packages/shared/src/markdownTaskList.test.ts b/packages/shared/src/markdownTaskList.test.ts new file mode 100644 index 0000000000000..5e00a3926fa54 --- /dev/null +++ b/packages/shared/src/markdownTaskList.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { markdownTaskMarkerOffset, setMarkdownTaskChecked } from "./markdownTaskList.ts"; + +describe("setMarkdownTaskChecked", () => { + const markdown = "- [ ] First\n- [x] Second\n"; + + it("checks and unchecks the task marker at the supplied offset", () => { + expect(setMarkdownTaskChecked(markdown, 2, true)).toBe("- [x] First\n- [x] Second\n"); + expect(setMarkdownTaskChecked(markdown, 14, false)).toBe("- [ ] First\n- [ ] Second\n"); + expect(setMarkdownTaskChecked("1. [X] Ordered\n", 3, false)).toBe("1. [ ] Ordered\n"); + }); + + it("leaves the document unchanged for a stale or invalid marker offset", () => { + expect(setMarkdownTaskChecked(markdown, 0, true)).toBe(markdown); + expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown); + }); +}); + +describe("markdownTaskMarkerOffset", () => { + const markdown = [ + "## Test plan", + "- [ ] `vp lint` passes", + " - [x] Nested **step**", + "```md", + "- [ ] Inside a fence", + "```", + "> 1. [X] Quoted [link](https://example.com)", + "- [ ] Wrapped", + " onto two lines", + ].join("\n"); + const marker = (offset: number | null) => + offset === null ? null : markdown.slice(offset, offset + 3); + + it("finds the tapped checkbox, skipping look-alikes in code", () => { + expect(marker(markdownTaskMarkerOffset(markdown, 0, "vp lint passes", true))).toBe("[ ]"); + expect(marker(markdownTaskMarkerOffset(markdown, 1, "Nested step", false))).toBe("[x]"); + // Native numbering restarts after code blocks and blockquotes. + expect(marker(markdownTaskMarkerOffset(markdown, 0, "Quoted link", false))).toBe("[X]"); + // Android cuts the text at a line break, a soft one too with hard line breaks; iOS doesn't. + for (const text of ["Wrapped", "Wrapped onto two lines"]) { + expect(markdownTaskMarkerOffset(markdown, 0, text, true)).toBe(markdown.lastIndexOf("[ ]")); + } + }); + + it("tells identical labels apart by count within one list and by state", () => { + const same = "- [ ] Same\n- [ ] Same\n\n```\nx\n```\n\n- [x] Same\n"; + expect(markdownTaskMarkerOffset(same, 0, "Same", true)).toBe(2); + expect(markdownTaskMarkerOffset(same, 1, "Same", true)).toBe(13); + expect(markdownTaskMarkerOffset(same, 0, "Same", false)).toBe(same.lastIndexOf("[x]")); + }); + + it("refuses a checkbox it can't trace to exactly one task", () => { + expect(markdownTaskMarkerOffset(markdown, 1, "vp lint passes", true)).toBeNull(); + expect(markdownTaskMarkerOffset(markdown, 0, "vp lint passes", false)).toBeNull(); + expect(markdownTaskMarkerOffset(markdown, 0, "Wrapped onto", true)).toBeNull(); + expect(markdownTaskMarkerOffset(markdown, 4, "", true)).toBeNull(); + + const similar = "- [ ] Similar\n\n```text\nseparator\n```\n\n- [ ] Similar
    different\n"; + // iOS keeps hard breaks in the text, so it names the second task. + expect(markdownTaskMarkerOffset(similar, 0, "Similar
different", true)).toBe( + similar.lastIndexOf("[ ]"), + ); + // Android cuts it to "Similar", which both tasks could be. + expect(markdownTaskMarkerOffset(similar, 0, "Similar", true)).toBeNull(); + + for (const label of ["Similar [docs](https://github.com/pingdotgg/t3code)", "Similar $x$"]) { + const attachment = similar.replace("Similar
    different", label); + expect(markdownTaskMarkerOffset(attachment, 0, "Similar \uFFFC", true)).toBeNull(); + } + }); +}); diff --git a/packages/shared/src/markdownTaskList.ts b/packages/shared/src/markdownTaskList.ts new file mode 100644 index 0000000000000..fb02f757992b9 --- /dev/null +++ b/packages/shared/src/markdownTaskList.ts @@ -0,0 +1,94 @@ +import type { ListItem, Nodes } from "mdast"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + +const parser = unified().use(remarkParse).use(remarkGfm); + +/** Offset of the `[ ]` marker on the first line of the list item starting at `listItemStart`. */ +export function taskListMarkerOffset(markdown: string, listItemStart: number): number | null { + const firstLineEnd = markdown.indexOf("\n", listItemStart); + const firstLine = markdown.slice( + listItemStart, + firstLineEnd === -1 ? markdown.length : firstLineEnd, + ); + const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); + if (!match?.[1]) return null; + return listItemStart + firstLine.indexOf(match[1]); +} + +export function setMarkdownTaskChecked( + markdown: string, + markerOffset: number, + checked: boolean, +): string { + if ( + markerOffset < 0 || + markdown[markerOffset] !== "[" || + !/[ xX]/.test(markdown[markerOffset + 1] ?? "") || + markdown[markerOffset + 2] !== "]" + ) { + return markdown; + } + + return `${markdown.slice(0, markerOffset + 1)}${checked ? "x" : " "}${markdown.slice(markerOffset + 2)}`; +} + +/** Hard breaks as U+2028, as iOS draws them; soft breaks stay "\n". */ +function plainText(node: Nodes): string { + if (node.type === "text" || node.type === "inlineCode") return node.value; + if (node.type === "break" || (node.type === "html" && /^ text.replace(/[^\p{L}\p{N}]/gu, ""); + +/** iOS sends the first paragraph whole. Android cuts it at a hard break, or any break with hard breaks on. */ +function renderedTexts(item: ListItem): string[] { + const first = item.children[0]; + const text = first?.type === "paragraph" ? plainText(first) : ""; + return [text, text.replace(/\u2028[^]*$/, ""), text.replace(/[\u2028\n][^]*$/, "")].map( + lettersAndDigits, + ); +} + +// Native renderers give other top-level blocks (code, tables, quotes, media) their own views, and +// each view numbers its tasks from 0. Lists, headings and rules share the text view around them. +const sharedViewBlocks = new Set(["list", "heading", "thematicBreak"]); + +/** + * Marker offset of the task a native checkbox tap names. Null unless exactly one task has that + * text and state and could have that index under some split of the document into native views. + */ +export function markdownTaskMarkerOffset( + markdown: string, + index: number, + renderedText: string, + checked: boolean, +): number | null { + // Native attachments replace their labels, so the remaining text could name another task. + if (renderedText.includes("\uFFFC")) return null; + const tasks: ListItem[] = []; + const collect = (node: Nodes) => { + if (node.type === "listItem" && typeof node.checked === "boolean") tasks.push(node); + if ("children" in node) node.children.forEach(collect); + }; + const viewStarts = new Set([0]); + for (const block of parser.parse(markdown).children) { + const before = tasks.length; + collect(block); + if (sharedViewBlocks.has(block.type)) continue; + for (let count = before; count <= tasks.length; count++) viewStarts.add(count); + } + const rendered = lettersAndDigits(renderedText); + const matches = tasks.filter( + (task, position) => + task.checked === !checked && + viewStarts.has(position - index) && + renderedTexts(task).includes(rendered), + ); + const start = matches.length === 1 ? matches[0]?.position?.start.offset : undefined; + return start === undefined ? null : taskListMarkerOffset(markdown, start); +} diff --git a/packages/shared/src/mcpApp.test.ts b/packages/shared/src/mcpApp.test.ts index 7e2ee8cbd20e1..6dd3c7d011493 100644 --- a/packages/shared/src/mcpApp.test.ts +++ b/packages/shared/src/mcpApp.test.ts @@ -7,6 +7,7 @@ import { injectMcpAppCookieBootstrap, mcpAppContentSecurityPolicy, mcpAppToolCallableByApp, + mcpAppToolResourceUri, readMcpAppCsp, readMcpAppReference, } from "./mcpApp.ts"; @@ -137,6 +138,14 @@ describe("readMcpAppReference", () => { describe("tool metadata", () => { it("reads the resource URI from either spelling and honors app visibility", () => { + const uri = "ui://weather/card.html"; + expect(mcpAppToolResourceUri({ ui: { resourceUri: uri } })).toBe(uri); + expect( + mcpAppToolResourceUri({ ui: { visibility: ["model", "app"] }, "ui/resourceUri": uri }), + ).toBe(uri); + expect( + mcpAppToolResourceUri({ ui: { resourceUri: "https://weather/card.html" } }), + ).toBeUndefined(); expect(mcpAppToolCallableByApp(undefined)).toBe(true); expect(mcpAppToolCallableByApp({ ui: { visibility: ["model"] } })).toBe(false); expect(mcpAppToolCallableByApp({ ui: { visibility: ["model", "app"] } })).toBe(true); @@ -170,6 +179,13 @@ describe("mcpAppFromToolItem", () => { }; expect(mcpAppFromToolItem({ toolName: "evil.lookup", output: forged })).toBeUndefined(); expect(mcpAppFromToolItem({ toolName: "bank.transfer", output: forged })).toBeDefined(); + // Claude Code names the same tool `mcp____`. + expect(mcpAppFromToolItem({ toolName: "mcp__bank__transfer", output: forged })).toBeDefined(); + expect(mcpAppFromToolItem({ toolName: "mcp__evil__transfer", output: forged })).toBeUndefined(); + const connector = { t3McpApp: { ...forged.t3McpApp, server: "claude.ai Asana (Beta)" } }; + expect( + mcpAppFromToolItem({ toolName: "mcp__claude_ai_Asana_Beta__transfer", output: connector }), + ).toBeDefined(); }); it("keeps an app whose resource URI is longer than a name", async () => { diff --git a/packages/shared/src/mcpApp.ts b/packages/shared/src/mcpApp.ts index d83368eef4802..35a50306423e5 100644 --- a/packages/shared/src/mcpApp.ts +++ b/packages/shared/src/mcpApp.ts @@ -131,6 +131,31 @@ export function mcpAppReferencesEqual(left: McpAppReference, right: McpAppRefere ); } +/** Reads a tool's `_meta.ui.resourceUri`, or the deprecated flat `ui/resourceUri`. */ +export function mcpAppToolResourceUri(meta: unknown): string | undefined { + if (!Predicate.isObject(meta)) return undefined; + const uri = + (Predicate.isObject(meta.ui) ? meta.ui.resourceUri : undefined) ?? meta["ui/resourceUri"]; + return typeof uri === "string" && + uri.length <= MAX_RESOURCE_URI_LENGTH && + uri.startsWith(MCP_APP_RESOURCE_SCHEME) + ? uri + : undefined; +} + +export const claudeMcpToolNamePart = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_"); + +/** Claude Code's `mcp____` prefix; it also collapses and trims `_` in `claude.ai ` connector names. */ +export function claudeMcpToolPrefix(server: string): string { + const part = claudeMcpToolNamePart(server); + return `mcp__${server.startsWith("claude.ai ") ? part.replace(/_+/g, "_").replace(/^_|_$/g, "") : part}__`; +} + +/** Claude Code names MCP tools `mcp____`, replacing characters outside `[a-zA-Z0-9_-]`. */ +export function claudeMcpToolName(server: string, tool: string): string { + return `${claudeMcpToolPrefix(server)}${claudeMcpToolNamePart(tool)}`; +} + export function mcpAppToolCallableByApp(meta: unknown): boolean { const visibility = Predicate.isObject(meta) && Predicate.isObject(meta.ui) ? meta.ui.visibility : undefined; diff --git a/packages/shared/src/mcpResponsePayloads.test.ts b/packages/shared/src/mcpResponsePayloads.test.ts new file mode 100644 index 0000000000000..9a3737ab93f53 --- /dev/null +++ b/packages/shared/src/mcpResponsePayloads.test.ts @@ -0,0 +1,71 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import { responsePayloads } from "./mcpResponsePayloads.ts"; + +it.effect("reads SSE line endings across byte-chunk boundaries without splitting CRLF", () => + Effect.gen(function* () { + for (const separator of [ + "\n\n", + "\r\r", + "\r\n\r\n", + "\r\n\n", + "\n\r", + "\n\r\n", + "\r\r\n", + "\r\n\r", + ]) { + for (const dataLineSeparator of ["\n", "\r", "\r\n"]) { + const bytes = new TextEncoder().encode( + `: keep-alive${separator}data: {"id":1,${dataLineSeparator}data: "result":{"text":"café😀"}}${separator}` + + 'data: {"id":2,"result":{}}\n\ndata: {"id":3}', + ); + for (let cut = 0; cut <= bytes.length; cut += 1) { + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(bytes.subarray(0, cut)); + controller.enqueue(bytes.subarray(cut)); + controller.close(); + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ); + const payloads = yield* Stream.runCollect( + responsePayloads(response, (cause) => ({ + _tag: "ResponsePayloadError" as const, + cause, + separator, + dataLineSeparator, + cut, + })), + ); + expect( + Array.from(payloads), + JSON.stringify({ separator, dataLineSeparator, cut }), + ).toEqual([ + { id: 1, result: { text: "café😀" } }, + { id: 2, result: {} }, + ]); + } + } + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`data: {"id":4}${separator}`)); + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ); + const payloads = yield* Stream.runCollect( + responsePayloads(response, (cause) => ({ + _tag: "ResponsePayloadError" as const, + cause, + separator, + })).pipe(Stream.take(1)), + ); + expect(Array.from(payloads)).toEqual([{ id: 4 }]); + } + }), +); diff --git a/packages/shared/src/mcpResponsePayloads.ts b/packages/shared/src/mcpResponsePayloads.ts index fe5a89153d6e0..ac8d301c404ae 100644 --- a/packages/shared/src/mcpResponsePayloads.ts +++ b/packages/shared/src/mcpResponsePayloads.ts @@ -1,24 +1,26 @@ -// @effect-diagnostics preferSchemaOverJson:off +// @effect-diagnostics globalJsonInEffect:off import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; +const SSE_EVENT_SEPARATOR = /(?:\r\n|\r(?!\n)|\n){2}/u; + async function* sseDataLines(response: Response): AsyncGenerator { if (response.body === null) return; const decoder = new TextDecoder(); let buffered = ""; for await (const chunk of response.body) { buffered += decoder.decode(chunk as Uint8Array, { stream: true }); - let separatorIndex = buffered.search(/\n\n|\r\n\r\n/u); - while (separatorIndex !== -1) { - const rawEvent = buffered.slice(0, separatorIndex); - buffered = buffered.slice(separatorIndex).replace(/^(?:\r?\n){2}/u, ""); + let separator = SSE_EVENT_SEPARATOR.exec(buffered); + while (separator !== null) { + const rawEvent = buffered.slice(0, separator.index); + buffered = buffered.slice(separator.index + separator[0].length); const data = rawEvent - .split(/\r?\n/u) + .split(/\r\n|\r|\n/u) .filter((line) => line.startsWith("data:")) .map((line) => line.slice("data:".length).trimStart()) .join("\n"); if (data.length > 0) yield data; - separatorIndex = buffered.search(/\n\n|\r\n\r\n/u); + separator = SSE_EVENT_SEPARATOR.exec(buffered); } } } diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts index 3190afbc7928a..481b7af0377e0 100644 --- a/packages/shared/src/nodeSqliteClient.ts +++ b/packages/shared/src/nodeSqliteClient.ts @@ -443,8 +443,7 @@ const makeReader = Effect.fnUntraced(function* ( if (current === reader) current = undefined; void reader.worker.terminate(); } - // @effect-diagnostics-next-line effectSucceedWithVoid:off - The connection result distinguishes undefined from void. - if (pool.unavailable) return Effect.succeed(undefined); + if (pool.unavailable) return Effect.undefined; pool.unavailable = true; return Effect.logWarning("SQLite reader worker unavailable; reads use the main connection", { reason, @@ -452,8 +451,7 @@ const makeReader = Effect.fnUntraced(function* ( }); return Effect.suspend((): Effect.Effect => { - // @effect-diagnostics-next-line effectSucceedWithVoid:off - The connection result distinguishes undefined from void. - if (pool.unavailable) return Effect.succeed(undefined); + if (pool.unavailable) return Effect.undefined; let reader: NonNullable; try { reader = current ?? start(); @@ -473,8 +471,7 @@ const makeReader = Effect.fnUntraced(function* ( (startup): Effect.Effect => // A late ready event must not hand out a worker retired by another acquire timeout. pool.unavailable || current !== reader - ? // @effect-diagnostics-next-line effectSucceedWithVoid:off - The connection result distinguishes undefined from void. - Effect.succeed(undefined) + ? Effect.undefined : Option.isSome(startup) && startup.value.type === "ready" ? Effect.succeed(reader.connection) : turnOff( @@ -640,7 +637,7 @@ const startCheckpointWorker = Effect.fnUntraced(function* ( } const frames = writer.prepare("PRAGMA wal_checkpoint(NOOP)"); const sizeLimit = writer.prepare("PRAGMA journal_size_limit"); - const started = yield* Effect.try( + const started = Result.try( () => new NodeWorkerThreads.Worker(checkpointWorkerSource, { eval: true, @@ -651,9 +648,9 @@ const startCheckpointWorker = Effect.fnUntraced(function* ( idleMs: CHECKPOINT_IDLE_MS, }, }), - ).pipe(Effect.result); + ); if (Result.isFailure(started)) { - yield* warn(String(started.failure.cause)); + yield* warn(String(started.failure)); return undefined; } const worker = started.success; @@ -1242,6 +1239,7 @@ const make = Effect.fn("makeWithDatabase")(function* ( ); const client = yield* Client.make({ + driver: "node:sqlite", acquirer: transactionAcquirer, borrower, compiler, diff --git a/packages/shared/src/orchestrationV2Timeline.test.ts b/packages/shared/src/orchestrationV2Timeline.test.ts index 8d38d1db59b30..9ed07c4f09b2b 100644 --- a/packages/shared/src/orchestrationV2Timeline.test.ts +++ b/packages/shared/src/orchestrationV2Timeline.test.ts @@ -8,6 +8,20 @@ import { const runId = RunId.make("run:timeline-visibility"); const nodeId = NodeId.make("node:timeline-visibility"); +const otherRunId = RunId.make("run:timeline-visibility:other"); +const otherNodeId = NodeId.make("node:timeline-visibility:other"); +type VisibilityInput = Parameters[0]; + +const ordinaryStatuses = [ + "preparing", + "queued", + "starting", + "running", + "waiting", + "completed", + "interrupted", + "failed", +] as const; describe.each([ ["point", isOrchestrationV2TurnItemVisible], @@ -28,19 +42,26 @@ describe.each([ ).toBe(false); }); - it("keeps paired interruption results from superseded attempts", () => { - expect( - isVisible({ - item: { type: "run_interrupt_result", runId, nodeId }, - runs: [{ id: runId, status: "running" }], - attempts: [{ runId, rootNodeId: nodeId, status: "superseded" }], - items: [ - { type: "run_interrupt_request", runId, nodeId }, - { type: "run_interrupt_result", runId, nodeId }, - ], - }), - ).toBe(true); - }); + it.each([ + { status: "running", visible: true }, + { status: "cancelled", visible: true }, + { status: "rolled_back", visible: false }, + ] as const)( + "keeps paired superseded results unless the run is rolled back ($status)", + ({ status, visible }) => { + expect( + isVisible({ + item: { type: "run_interrupt_result", runId, nodeId }, + runs: [{ id: runId, status }], + attempts: [{ runId, rootNodeId: nodeId, status: "superseded" }], + items: [ + { type: "run_interrupt_request", runId, nodeId }, + { type: "run_interrupt_result", runId, nodeId }, + ], + }), + ).toBe(visible); + }, + ); it("keeps interruption results from terminal attempts without a request", () => { expect( @@ -92,4 +113,204 @@ describe.each([ }), ).toBe(true); }); + + it.each([ + ...ordinaryStatuses.map((status) => ({ status, hidden: [] })), + { status: "cancelled", hidden: ["queued"] }, + { + status: "rolled_back", + hidden: ["queued", "start", "steer", "promoted", "legacy", "assistant", "request", "result"], + }, + ] satisfies ReadonlyArray<{ + status: VisibilityInput["runs"][number]["status"]; + hidden: string[]; + }>)("keeps the correct transcript rows for $status runs", ({ status, hidden }) => { + const rows = [ + { id: "queued", type: "user_message", inputIntent: "queued_turn", runId, nodeId }, + { id: "start", type: "user_message", inputIntent: "turn_start", runId, nodeId }, + { id: "steer", type: "user_message", inputIntent: "steer", runId, nodeId }, + { + id: "promoted", + type: "user_message", + inputIntent: "promoted_queued_to_steer", + runId, + nodeId, + }, + { id: "legacy", type: "user_message", runId, nodeId }, + { id: "assistant", type: "assistant_message", inputIntent: "queued_turn", runId, nodeId }, + { id: "request", type: "run_interrupt_request", runId, nodeId }, + { id: "result", type: "run_interrupt_result", runId, nodeId }, + { id: "null", type: "user_message", inputIntent: "queued_turn", runId: null, nodeId }, + { + id: "missing", + type: "user_message", + inputIntent: "queued_turn", + runId: otherRunId, + nodeId, + }, + ] satisfies ReadonlyArray; + const input = { + runs: [{ id: runId, status }], + attempts: [], + items: rows, + } satisfies VisibilityInput; + expect(rows.filter((item) => isVisible({ ...input, item })).map((item) => item.id)).toEqual( + [ + "queued", + "start", + "steer", + "promoted", + "legacy", + "assistant", + "request", + "result", + "null", + "missing", + ].filter((id) => !hidden.some((hiddenId) => hiddenId === id)), + ); + }); + + it.each([ + { name: "empty history", runs: [], attempts: [] }, + { + name: "ordinary attempts", + runs: [{ id: runId, status: "completed" }], + attempts: [{ runId, rootNodeId: nodeId, status: "completed" }], + }, + ] satisfies ReadonlyArray & { name: string }>)( + "keeps all rows with $name", + ({ runs, attempts }) => { + const items = [ + { type: "user_message", inputIntent: "queued_turn", runId, nodeId }, + { type: "run_interrupt_result", runId, nodeId }, + { type: "assistant_message", runId: null, nodeId: null }, + ] satisfies VisibilityInput["items"]; + expect(items.filter((item) => isVisible({ item, runs, attempts, items }))).toEqual(items); + }, + ); + + it.each([ + { name: "unpaired", runId, nodeId, requests: [], visible: false }, + { name: "second root", runId, nodeId: otherNodeId, requests: [], visible: false }, + { name: "other run", runId: otherRunId, nodeId, requests: [], visible: true }, + { + name: "unknown root", + runId, + nodeId: NodeId.make("node:unknown"), + requests: [], + visible: true, + }, + { name: "null run", runId: null, nodeId, requests: [], visible: true }, + { name: "null node", runId, nodeId: null, requests: [], visible: true }, + { name: "other request", runId, nodeId, requests: [otherRunId], visible: false }, + { name: "null request", runId, nodeId, requests: [null], visible: false }, + { name: "paired request", runId, nodeId, requests: [runId], visible: true }, + ] satisfies ReadonlyArray<{ + name: string; + runId: VisibilityInput["items"][number]["runId"]; + nodeId: VisibilityInput["items"][number]["nodeId"]; + requests: VisibilityInput["items"][number]["runId"][]; + visible: boolean; + }>)("matches superseded roots and requests for $name results", (row) => { + const item = { type: "run_interrupt_result", runId: row.runId, nodeId: row.nodeId } as const; + const requests = row.requests.map((requestRunId) => ({ + type: "run_interrupt_request" as const, + runId: requestRunId, + nodeId: otherNodeId, + })); + for (const items of [ + [...requests, item], + [item, ...requests], + ]) { + expect( + isVisible({ + item, + runs: [], + attempts: [ + { runId, rootNodeId: nodeId, status: "superseded" }, + { runId, rootNodeId: otherNodeId, status: "superseded" }, + ], + items, + }), + ).toBe(row.visible); + } + }); +}); + +describe("indexed timeline visibility", () => { + it.each([ + ...ordinaryStatuses.flatMap( + (status) => + [ + { statuses: ["rolled_back", status], visible: true, ordinaryVisible: true }, + { statuses: ["cancelled", status], visible: true, ordinaryVisible: true }, + { statuses: [status, "rolled_back"], visible: false, ordinaryVisible: false }, + { statuses: [status, "cancelled"], visible: false, ordinaryVisible: true }, + ] as const, + ), + { statuses: ["rolled_back", "cancelled"], visible: false, ordinaryVisible: true }, + { statuses: ["cancelled", "rolled_back"], visible: false, ordinaryVisible: false }, + { + statuses: ["rolled_back", "running", "cancelled", "completed"], + visible: true, + ordinaryVisible: true, + }, + ] satisfies ReadonlyArray<{ + statuses: ReadonlyArray; + visible: boolean; + ordinaryVisible: boolean; + }>)( + "uses the last duplicate run status in $statuses", + ({ statuses, visible, ordinaryVisible }) => { + const queued = { type: "user_message", inputIntent: "queued_turn", runId, nodeId } as const; + const ordinary = { type: "assistant_message", runId, nodeId } as const; + const isVisible = createOrchestrationV2TurnItemVisibility({ + runs: statuses.map((status) => ({ id: runId, status })), + attempts: [], + items: [queued, ordinary], + }); + expect(isVisible(queued)).toBe(visible); + expect(isVisible(ordinary)).toBe(ordinaryVisible); + }, + ); + + it("preserves frozen rows and reads fresh status and attempt snapshots", () => { + const item = Object.freeze({ type: "run_interrupt_result", runId, nodeId } as const); + const queued = Object.freeze({ + type: "user_message", + inputIntent: "queued_turn", + runId, + nodeId, + } as const); + const items = Object.freeze([item, queued]); + const attempts = Object.freeze([ + Object.freeze({ runId, rootNodeId: nodeId, status: "superseded" } as const), + ]); + const runs = Object.freeze([Object.freeze({ id: runId, status: "running" } as const)]); + const input = Object.freeze({ runs, attempts, items }); + const before = structuredClone(input); + const visible = items.filter(createOrchestrationV2TurnItemVisibility(input)); + expect(visible).toEqual([queued]); + expect(visible[0]).toBe(queued); + expect(input).toEqual(before); + expect( + items.filter(createOrchestrationV2TurnItemVisibility({ ...input, attempts: [] })), + ).toEqual(items); + expect( + items.filter( + createOrchestrationV2TurnItemVisibility({ + ...input, + runs: [{ id: runId, status: "cancelled" }], + }), + ), + ).toEqual([]); + expect( + items.filter( + createOrchestrationV2TurnItemVisibility({ + ...input, + items: [...items, { type: "run_interrupt_request", runId, nodeId: null }], + }), + ), + ).toEqual(items); + }); }); diff --git a/packages/shared/src/orchestrationV2Timeline.ts b/packages/shared/src/orchestrationV2Timeline.ts index 6ca8a95f7fb5a..05f13557138ba 100644 --- a/packages/shared/src/orchestrationV2Timeline.ts +++ b/packages/shared/src/orchestrationV2Timeline.ts @@ -74,7 +74,12 @@ export function createOrchestrationV2TurnItemVisibility(input: { readonly attempts: ReadonlyArray; readonly items: ReadonlyArray; }): (item: TimelineTurnItem) => boolean { - const statuses = new Map(input.runs.map((run) => [run.id, run.status])); + const statuses = new Map(); + for (const run of input.runs) { + if (run.status === "rolled_back" || run.status === "cancelled") + statuses.set(run.id, run.status); + else statuses.delete(run.id); + } const supersededRoots = new Map< TimelineRunAttempt["runId"], Set @@ -85,9 +90,13 @@ export function createOrchestrationV2TurnItemVisibility(input: { if (roots === undefined) supersededRoots.set(attempt.runId, (roots = new Set())); roots.add(attempt.rootNodeId); } - const interruptRuns = new Set( - input.items.filter((item) => item.type === "run_interrupt_request").map((item) => item.runId), - ); + if (statuses.size === 0 && supersededRoots.size === 0) return () => true; + const interruptRuns = new Set(); + if (supersededRoots.size > 0) { + for (const item of input.items) { + if (item.type === "run_interrupt_request") interruptRuns.add(item.runId); + } + } return (item) => { const status = item.runId === null ? undefined : statuses.get(item.runId); if (status === "rolled_back") return false; diff --git a/packages/shared/src/paletteSearch.test.ts b/packages/shared/src/paletteSearch.test.ts index 080f4f7e30b84..9b2fdc44bdb24 100644 --- a/packages/shared/src/paletteSearch.test.ts +++ b/packages/shared/src/paletteSearch.test.ts @@ -663,6 +663,26 @@ describe("scoreMessageMatch", () => { expect(scoreMessageMatch("We trust the build", "rust")).toEqual({ quality: 0.3, offset: 4 }); expect(scoreMessageMatch("Rust and trust", "rust")).toEqual({ quality: 1, offset: 0 }); expect(scoreMessageMatch("Use the sidebar", '"sidebar"')).toEqual({ quality: 1, offset: 8 }); + for (const [text, query, expected] of [ + ["model mode mode", "mode", { quality: 1, offset: 6 }], + ["xmode model models", "mode", { quality: 0.8, offset: 6 }], + ["model models", "mode", { quality: 0.8, offset: 0 }], + ["xmode ymode", "mode", { quality: 0.3, offset: 1 }], + ["mode mode", "mode", { quality: 1, offset: 0 }], + ["fts fts", "fts", { quality: 1, offset: 0 }], + ["the theme", "he", null], + ["he theme", "he", { quality: 1, offset: 0 }], + ["notes", "", { quality: 1, offset: 0 }], + ["", "notes", null], + ["same same same", "same same", { quality: 1.25, offset: 0 }], + ["7cafe52bb8 cafe cafe", "cafe", { quality: 1, offset: 11 }], + ["7cafe52bb8 7cafe52bb8", "7cafe52", { quality: 1, offset: 0 }], + ["明天部署部署到生产环境", "部署", { quality: 1, offset: 2 }], + ["gannet’s gannet's", "gannet's", { quality: 1, offset: 9 }], + ["gannet's gannet’s", "gannet’s", { quality: 1, offset: 0 }], + ] as const) { + expect(scoreMessageMatch(text, query), `${query} in ${text}`).toEqual(expected); + } // Words written together as typed rank above the same words apart, and // the snippet starts where they are together. const together = "Run the tests, then vp run dev to check"; @@ -738,5 +758,14 @@ describe("scoreMessageMatch", () => { expect(scoreMessageMatch("İ Enable fts and model", "fts")).toMatchObject({ offset: 9 }); expect(scoreMessageMatch("İ Enable fts and model", "model")?.quality).toBe(1); expect(scoreMessageMatch("Trip to İstanbul", "İstanbul")).toEqual({ quality: 1, offset: 8 }); + for (const [text, query, expected] of [ + ["İ cafeteria Café CAFÉ", "cafe", { quality: 1, offset: 12 }], + ["Café cafe", "cafe", { quality: 1, offset: 5 }], + ["İ Café Café", "café", { quality: 1, offset: 2 }], + ["İ Résumé Résumé", "resume", { quality: 1, offset: 2 }], + ["İ résuméprefix résumé", "resume", { quality: 1, offset: 15 }], + ] as const) { + expect(scoreMessageMatch(text, query), `${query} in ${text}`).toEqual(expected); + } }); }); diff --git a/packages/shared/src/paletteSearch.ts b/packages/shared/src/paletteSearch.ts index 1b088697ec1f8..adc6a6851c643 100644 --- a/packages/shared/src/paletteSearch.ts +++ b/packages/shared/src/paletteSearch.ts @@ -535,6 +535,7 @@ function findMessageToken( text: string, folded: string, token: string, + firstOnly: boolean, ): { readonly at: ReadonlyArray; readonly quality: number } | null { let quality = 0; let at: number[] = []; @@ -559,7 +560,8 @@ function findMessageToken( if (found > quality) { quality = found; at = [index]; - } else if (found === quality) { + if (firstOnly && quality === 1) return { at, quality }; + } else if (!firstOnly && found === quality) { at.push(index); } } @@ -611,10 +613,15 @@ export function messageMatcher(query: string): (text: string) => MessageMatch | let quality = 1; const positions: Array> = []; for (const { token, folded: foldedToken, nonAscii, mustStartWord, grades } of tokens) { - let found = findMessageToken(text, plain, token); + let found = findMessageToken(text, plain, token, tokens.length === 1); // Accents typed or left out: "cafe" finds "Café", also after "cafeteria". if (found?.quality !== 1 && foldedToken.length > 0 && (nonAscii || NON_ASCII.test(text))) { - found = findMessageToken(text, (folded ??= foldInPlace(text)), foldedToken); + found = findMessageToken( + text, + (folded ??= foldInPlace(text)), + foldedToken, + tokens.length === 1, + ); } if (found === null) return null; if (found.quality === MID_WORD && mustStartWord) return null; diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 9b0ccd8826213..1d6348852722c 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -106,6 +106,21 @@ describe("workspaceRelativeFilePath", () => { ["/repo/project/../other/notes.md", "/repo/project", null], ["C:\\repo\\project\\src\\..\\..\\notes.md", "C:\\repo\\project", null], ["/repo/project/docs/a/../b.md", "/repo/project", "docs/a/../b.md"], + ["/repo/a\\b.txt", "/repo", "a\\b.txt"], + ["/repo/a/b.txt", "/repo", "a/b.txt"], + ["/repo/a\\", "/repo", "a\\"], + ["/repo/..\\notes.md", "/repo", "..\\notes.md"], + ["/repo/a\\/../../notes.md", "/repo", null], + ["/repo\\project/a.txt", "/repo\\project/", "a.txt"], + ["/repo/project/a.txt", "/repo\\project", null], + ["/repo\\project/a.txt", "/repo/project", null], + ["C:\\Repo\\src\\main.ts", "c:/repo/", "src/main.ts"], + ["/C:/Repo/src/main.ts", "c:\\repo", "src/main.ts"], + ["C:\\Repo\\src\\main.ts", "/c:/repo", "src/main.ts"], + ["\\\\SERVER\\Share\\Repo\\src\\main.ts", "\\\\server\\share\\repo", "src/main.ts"], + ["\\\\SERVER\\Share\\Repo\\src\\main.ts", "//server/share/repo", "src/main.ts"], + ["//server/share/a\\b.txt", "//server/share", "a\\b.txt"], + ["\\\\server\\share\\repo\\..\\notes.md", "\\\\server\\share\\repo", null], ])("relates %s to %s", (path, workspaceRoot, relativePath) => { expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath); }); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index a7cce7cb88f09..e2c3cd3117aa2 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -95,11 +95,17 @@ export function workspaceRelativeFilePath( workspaceRoot: string | null | undefined, ): string | null { if (!workspaceRoot) return null; - const normalizedPath = stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/")); - const normalizedRoot = stripSlashPrefixedWindowsDrive( - workspaceRoot.replaceAll("\\", "/"), + const caseInsensitive = + isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)) || + (isNetworkPath(workspaceRoot) && isUncPath(path)); + const normalizedPath = caseInsensitive + ? stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/")) + : path; + const normalizedRoot = ( + caseInsensitive + ? stripSlashPrefixedWindowsDrive(workspaceRoot.replaceAll("\\", "/")) + : workspaceRoot ).replace(/\/+$/, ""); - const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)); const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; if (pathForCompare.replace(/\/+$/, "") === rootForCompare) return "."; diff --git a/packages/shared/src/semver.test.ts b/packages/shared/src/semver.test.ts index a66e5b07d8bcc..824dd7d6f98d4 100644 --- a/packages/shared/src/semver.test.ts +++ b/packages/shared/src/semver.test.ts @@ -56,15 +56,27 @@ describe("semver helpers", () => { it("supports comparison comparators", () => { expect(satisfiesSemverRange("24.9.0", ">=24.0 <24.10")).toBe(true); expect(satisfiesSemverRange("24.10.0", ">=24.0 <24.10")).toBe(false); + for (const gap of [" ", "\t", "\n"]) { + const range = `>=${gap}24.0 <${gap}24.10`; + expect(satisfiesSemverRange("24.9.0", range)).toBe(true); + expect(satisfiesSemverRange("24.10.0", range)).toBe(false); + expect(satisfiesSemverRange("22.16.0", `^${gap}22.16 || >=${gap}24.10`)).toBe(true); + } }); it("honors caret range upper bounds for zero-major versions", () => { + expect(satisfiesSemverRange("0.5.0", "^0")).toBe(true); + expect(satisfiesSemverRange("0.0.4", "^0.0")).toBe(true); + expect(satisfiesSemverRange("1.0.0", "^0")).toBe(false); + expect(satisfiesSemverRange("0.1.0", "^0.0")).toBe(false); expect(satisfiesSemverRange("0.2.3", "^0.2.3")).toBe(true); expect(satisfiesSemverRange("0.2.9", "^0.2.3")).toBe(true); expect(satisfiesSemverRange("0.3.0", "^0.2.3")).toBe(false); expect(satisfiesSemverRange("0.5.0", "^0.2.3")).toBe(false); expect(satisfiesSemverRange("0.0.3", "^0.0.3")).toBe(true); expect(satisfiesSemverRange("0.0.4", "^0.0.3")).toBe(false); + expect(satisfiesSemverRange("0.0.0", "^0.0.0")).toBe(true); + expect(satisfiesSemverRange("0.0.1", "^0.0.0")).toBe(false); }); it("keeps the range checker stringifiable and executable as plain JavaScript", () => { @@ -74,6 +86,8 @@ describe("semver helpers", () => { expect(source).toContain("function satisfiesSemverRange"); expect(source).not.toContain(": string"); expect(source).not.toContain(": boolean"); + expect(recreated("0.5.0", "^0")).toBe(true); + expect(recreated("0.0.4", "^0.0")).toBe(true); expect(recreated("24.10.0", ">=24.10")).toBe(true); expect(recreated("24.9.9", ">=24.10")).toBe(false); }); diff --git a/packages/shared/src/semver.ts b/packages/shared/src/semver.ts index 8d1a354f008bc..720ea68a75066 100644 --- a/packages/shared/src/semver.ts +++ b/packages/shared/src/semver.ts @@ -156,7 +156,11 @@ export const satisfiesSemverRange: (rawVersion: string, range: string) => boolea }; return range.split("||").some((group) => { - const comparators = group.trim().split(/\s+/).filter(Boolean); + const comparators = group + .trim() + .replace(/(\^|>=|>|<=|<|=)\s+(?=v?\d)/g, "$1") + .split(/\s+/) + .filter(Boolean); if (comparators.length === 0) { return false; } @@ -198,10 +202,10 @@ export const satisfiesSemverRange: (rawVersion: string, range: string) => boolea if (compared < 0) { return false; } - if (target.major > 0) { + if (target.major > 0 || targetMatch[2] === undefined) { return version.major === target.major; } - if (target.minor > 0) { + if (target.minor > 0 || targetMatch[3] === undefined) { return version.major === 0 && version.minor === target.minor; } return version.major === 0 && version.minor === 0 && version.patch === target.patch; diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index a897f3d427a34..01c921d80c631 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -127,6 +127,24 @@ describe("serverSettings helpers", () => { }); }); + it("keeps a source control host's saved tokens when a patch sets another host's token", () => { + const reference = { provider: "vault", reference: "github/token" }; + const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + sourceControlHosts: { github: { tokens: { "github.com": reference } } }, + }); + const patched = applyServerSettingsPatch(saved, { + sourceControlHosts: { github: { tokens: { "ghe.example": "x" } } }, + }); + expect(patched.sourceControlHosts.github?.tokens).toEqual({ + "github.com": reference, + "ghe.example": "x", + }); + const recased = applyServerSettingsPatch(saved, { + sourceControlHosts: { github: { tokens: { " GitHub.com ": "ghp_new" } } }, + }); + expect(recased.sourceControlHosts.github?.tokens).toEqual({ "github.com": "ghp_new" }); + }); + it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { const project = { id: ProjectId.make("project-actions"), scripts: [] }; const action = { @@ -253,6 +271,15 @@ describe("serverSettings helpers", () => { otlpMetricsUrl: "http://localhost:4318/v1/metrics", otlpLogsUrl: "http://localhost:4318/v1/logs", }); + // The server still loads a file holding the retired token streaming mode. + expect( + parsePersistedServerObservabilitySettings( + JSON.stringify({ + responseStreamingMode: "token", + observability: { otlpTracesUrl: "http://localhost:4318/v1/traces" }, + }), + ).otlpTracesUrl, + ).toBe("http://localhost:4318/v1/traces"); }); it("falls back cleanly when persisted settings are invalid", () => { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index e936c9f83acba..d6ba8c89fe5f3 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -23,6 +23,7 @@ import { withIdleCompaction, } from "@t3tools/contracts"; import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import { deepMerge } from "./Struct.ts"; import { fromLenientJson } from "./schemaJson.ts"; @@ -34,8 +35,13 @@ import { resolveBackgroundActivitySettings, } from "./backgroundActivitySettings.ts"; -const ServerSettingsJson = fromLenientJson(ServerSettings); -const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); +// Only observability: a value the server migrates on load elsewhere in the file must not drop it. +const ServerObservabilitySettingsJson = fromLenientJson( + Schema.Struct({ observability: ServerSettings.fields.observability }), +); +const decodeServerObservabilitySettingsJson = Schema.decodeUnknownOption( + ServerObservabilitySettingsJson, +); export function isModelSelectionProviderEnabled( settings: ServerSettings, @@ -180,7 +186,7 @@ function extractPersistedServerObservabilitySettings(input: { export function parsePersistedServerObservabilitySettings( raw: string, ): PersistedServerObservabilitySettings { - const decoded = decodeServerSettingsJson(raw); + const decoded = decodeServerObservabilitySettingsJson(raw); if (Option.isSome(decoded)) { return extractPersistedServerObservabilitySettings(decoded.value); } @@ -226,6 +232,15 @@ function mergeSettingsEntries( return Object.fromEntries(next); } +// Secret persistence keys tokens by trimmed, lowercased host, so merge on the same key. +const byHost = (tokens: unknown) => + Object.fromEntries( + Object.entries(Predicate.isObject(tokens) ? tokens : {}).map(([host, token]) => [ + host.trim().toLowerCase(), + token, + ]), + ); + const knownProjectOverrideKeys = new Set(Object.keys(ProjectSettingsOverrides.fields)); function pickOverrideKeys(entry: ProjectSettingsOverrides | undefined, known: boolean) { @@ -530,10 +545,26 @@ export function applyServerSettingsPatch( sourceControlHosts: { ...current.sourceControlHosts, ...Object.fromEntries( - Object.entries(sourceControlHostsPatch).map(([kind, fields]) => [ - kind, - { ...current.sourceControlHosts[kind], ...fields }, - ]), + Object.entries(sourceControlHostsPatch).map(([kind, fields]) => { + const saved = current.sourceControlHosts[kind]; + return [ + kind, + { + ...saved, + ...fields, + // Older clients send the whole token map; a host missing from it keeps its saved + // token. An empty map still clears them all. + ...(Predicate.isObject(fields.tokens) && Object.keys(fields.tokens).length > 0 + ? { + tokens: { + ...byHost(saved?.tokens), + ...byHost(fields.tokens), + }, + } + : {}), + }, + ]; + }), ), }, } diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index ef1e63645c515..a7c3ee58e14f3 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -7,6 +7,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Option from "effect/Option"; +import * as TestClock from "effect/testing/TestClock"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; @@ -37,10 +38,12 @@ import { const nativeProbe = vi.hoisted(() => ({ execFile: vi.fn(), + original: undefined as unknown as typeof import("node:child_process").execFile, })); vi.mock("node:child_process", async (importOriginal) => { const original = await importOriginal(); + nativeProbe.original = original.execFile; return { ...original, execFile: nativeProbe.execFile.mockImplementation(original.execFile) }; }); @@ -270,7 +273,7 @@ describe("readEnvironmentFromWindowsShell", () => { Effect.gen(function* () { const started = Promise.withResolvers(); const kill = vi.fn(() => true); - const child = { kill } as unknown as ReturnType< + const child = { kill, unref: vi.fn() } as unknown as ReturnType< typeof import("node:child_process").execFile >; const execFile = nativeProbe.execFile; @@ -292,6 +295,32 @@ describe("readEnvironmentFromWindowsShell", () => { }), ); + effectIt.effect("gives up on a shell that survives the kill and holds stdout open", () => + Effect.gen(function* () { + const stdout = { destroy: vi.fn() }; + const unref = vi.fn(); + const child = { kill: vi.fn(() => true), stdout, unref } as unknown as ReturnType< + typeof import("node:child_process").execFile + >; + const execFile = nativeProbe.execFile; + execFile.mockClear(); + execFile.mockImplementation(() => child); + try { + const fiber = yield* readEnvironmentFromWindowsShell(["PATH"]).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("6 seconds"); + expect(execFile.mock.calls.map(([file]) => file)).toEqual(["pwsh.exe", "powershell.exe"]); + yield* TestClock.adjust("6 seconds"); + expect(yield* Fiber.join(fiber)).toEqual({}); + expect(stdout.destroy).toHaveBeenCalledTimes(2); + expect(unref).toHaveBeenCalledTimes(2); + } finally { + execFile.mockReset(); + execFile.mockImplementation(nativeProbe.original); + } + }), + ); + effectIt.effect("extracts environment variables from a PowerShell command", () => Effect.gen(function* () { const execFile = vi.fn< @@ -307,7 +336,7 @@ describe("readEnvironmentFromWindowsShell", () => { expect( yield* readEnvironmentFromWindowsShell(["PATH"], {}, (...args) => - Effect.try(() => execFile(...args)).pipe(Effect.option), + Effect.sync(() => Option.liftThrowable(execFile)(...args)), ), ).toEqual({ PATH: "C:\\Users\\testuser\\AppData\\Roaming\\npm", @@ -335,7 +364,7 @@ describe("readEnvironmentFromWindowsShell", () => { expect( yield* readEnvironmentFromWindowsShell(["FNM_DIR"], {}, (...args) => - Effect.try(() => execFile(...args)).pipe(Effect.option), + Effect.sync(() => Option.liftThrowable(execFile)(...args)), ), ).toEqual({ FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", @@ -355,7 +384,7 @@ describe("readEnvironmentFromWindowsShell", () => { expect( yield* readEnvironmentFromWindowsShell(["PATH"], { loadProfile: true }, (...args) => - Effect.try(() => execFile(...args)).pipe(Effect.option), + Effect.sync(() => Option.liftThrowable(execFile)(...args)), ), ).toEqual({ PATH: "C:\\Tools", @@ -386,7 +415,7 @@ describe("readEnvironmentFromWindowsShell", () => { expect( yield* readEnvironmentFromWindowsShell(["PATH"], {}, (...args) => - Effect.try(() => execFile(...args)).pipe(Effect.option), + Effect.sync(() => Option.liftThrowable(execFile)(...args)), ), ).toEqual({ PATH: "C:\\Tools", diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 75e2307b925b2..649241ff3b932 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -5,6 +5,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -16,6 +17,9 @@ const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; const WINDOWS_PATH_DELIMITER = ";"; const POSIX_PATH_DELIMITER = ":"; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; +const WINDOWS_SHELL_PROBE_TIMEOUT_MS = 5000; +// A shell stuck in the kernel survives the kill and holds stdout, so execFile's callback never fires. +const WINDOWS_SHELL_PROBE_DEADLINE = Duration.millis(WINDOWS_SHELL_PROBE_TIMEOUT_MS + 1000); type ExecFileSyncLike = ( file: string, @@ -359,6 +363,9 @@ const execWindowsEnvironmentProbe = ( } return Effect.sync(() => { child.kill(); + child.stdout?.destroy(); + child.stderr?.destroy(); + child.unref(); }); }); @@ -379,7 +386,10 @@ export const readEnvironmentFromWindowsShell = Effect.fn("shell.readEnvironmentF command, ]; for (const shell of WINDOWS_SHELL_CANDIDATES) { - const output = yield* execFile(shell, args, { encoding: "utf8", timeout: 5000 }); + const output = yield* execFile(shell, args, { + encoding: "utf8", + timeout: WINDOWS_SHELL_PROBE_TIMEOUT_MS, + }).pipe(Effect.timeoutOption(WINDOWS_SHELL_PROBE_DEADLINE), Effect.map(Option.flatten)); if (Option.isNone(output)) continue; const environment: Partial> = {}; diff --git a/packages/shared/src/socketFrameParts.ts b/packages/shared/src/socketFrameParts.ts new file mode 100644 index 0000000000000..e0c82a87de3bb --- /dev/null +++ b/packages/shared/src/socketFrameParts.ts @@ -0,0 +1,81 @@ +import * as Arr from "effect/Array"; +import * as Effect from "effect/Effect"; +import * as Socket from "effect/socket/Socket"; + +// Old clients omit this; old servers ignore it. +export const SOCKET_FRAME_PARTS_QUERY_PARAM = "frameParts"; + +// WebSocket messages arrive whole. At 4,000 bytes/s each part takes about +// 12 seconds, leaving margin before the client's 15-second liveness timeout. +const SOCKET_FRAME_PART_BYTES = 48 * 1024; + +// No JSON message starts with a record separator. It marks every part but the last. +const PART_PREFIX = "\u001e"; + +// The RPC client only counts frames that decode to messages as liveness, and +// ignores pongs, so each held-back part reads to it as one. +const PART_RECEIVED_FRAME = '{"_tag":"Pong"}'; + +/** Splits on the UTF-8 size the frame has on the wire, never inside a surrogate pair. */ +export function splitSocketFrame( + frame: string, + partBytes = SOCKET_FRAME_PART_BYTES, +): Arr.NonEmptyReadonlyArray { + // UTF-8 spends at most 3 bytes on a UTF-16 code unit. + if (frame.length * 3 <= partBytes) return [frame]; + const parts: Array = []; + let start = 0; + let bytes = PART_PREFIX.length; + for (let index = 0; index < frame.length;) { + const code = frame.charCodeAt(index); + const pair = code >= 0xd800 && code <= 0xdbff && index + 1 < frame.length; + const size = pair ? 4 : code < 0x80 ? 1 : code < 0x800 ? 2 : 3; + if (bytes + size > partBytes) { + parts.push(PART_PREFIX + frame.slice(start, index)); + start = index; + bytes = PART_PREFIX.length; + } + bytes += size; + index += pair ? 2 : 1; + } + return Arr.append(parts, frame.slice(start)); +} + +/** Server side: sends string frames longer than one part as consecutive parts. */ +export function splitLargeSocketFrames(socket: Socket.Socket): Socket.Socket { + const split = (chunk: string | Uint8Array): Arr.NonEmptyReadonlyArray => + typeof chunk === "string" ? splitSocketFrame(chunk) : [chunk]; + return Socket.make({ + reader: socket.reader, + writer: Effect.map(socket.writer, (writer) => ({ + write: (chunk) => + typeof chunk === "string" ? writer.writeAll(splitSocketFrame(chunk)) : writer.write(chunk), + writeAll: (chunks) => writer.writeAll(Arr.flatMap(chunks, split)), + })), + }); +} + +/** Client side: joins parts back into whole frames. */ +export function joinSocketFrameParts(socket: Socket.Socket): Socket.Socket { + return Socket.make({ + reader: Effect.map(socket.reader, (reader) => { + let pending = ""; + const join = (frame: string | Uint8Array) => { + if (typeof frame !== "string") return frame; + if (frame.startsWith(PART_PREFIX)) { + pending += frame.slice(PART_PREFIX.length); + return PART_RECEIVED_FRAME; + } + if (pending === "") return frame; + const whole = pending + frame; + pending = ""; + return whole; + }; + return { + upgrade: reader.upgrade, + pull: Effect.map(reader.pull, Arr.map(join)), + }; + }), + writer: socket.writer, + }); +} diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts new file mode 100644 index 0000000000000..4e892996d34c4 --- /dev/null +++ b/packages/shared/src/sourceControl.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isSshRemoteUrl } from "./sourceControl.ts"; + +describe("isSshRemoteUrl", () => { + it.each([ + "git@github.com:owner/repo.git", + "ssh://git@github.com/owner/repo.git", + "github.com:owner/repo.git", + "github-work:owner/repo.git", + ])("reads %s as SSH", (remoteUrl) => { + expect(isSshRemoteUrl(remoteUrl)).toBe(true); + }); + + it.each([ + "https://github.com/owner/repo.git", + "C:/repos/repo", + "/srv/git/repo.git", + "./team:group/repo", + "file:///srv/git/repo.git", + "hg::owner/repo", + ])("does not read %s as SSH", (remoteUrl) => { + expect(isSshRemoteUrl(remoteUrl)).toBe(false); + }); +}); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 7ac5fee20b20b..91c347f8a50d6 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -1,8 +1,20 @@ const SCP_SSH_REMOTE_PATTERN = /^[a-zA-Z0-9._-]+@([^:/]+):/; +// Git reads `host:path` as SSH too, leaving the user to ssh config. A single letter is a Windows +// drive and `::` names a remote helper. +const USERLESS_SCP_SSH_REMOTE_PATTERN = /^([^@:/\\\s]{2,}):(?!:)\/?[^/\s]+(?:\/[^/\s]+)+\/?$/u; + +/** Null unless the remote is `host:path`, with no user. */ +export function userlessScpRemoteHost(remoteUrl: string): string | null { + return USERLESS_SCP_SSH_REMOTE_PATTERN.exec(remoteUrl.trim())?.[1] ?? null; +} export function isSshRemoteUrl(remoteUrl: string): boolean { const trimmed = remoteUrl.trim(); - return SCP_SSH_REMOTE_PATTERN.test(trimmed) || trimmed.toLowerCase().startsWith("ssh://"); + return ( + SCP_SSH_REMOTE_PATTERN.test(trimmed) || + trimmed.toLowerCase().startsWith("ssh://") || + userlessScpRemoteHost(trimmed) !== null + ); } /** diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 10619770590be..fc3acca765ba2 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -372,8 +372,24 @@ function normalizeT3McpToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } -// ACP tool prefixes vary; match loosely but require a known display-name entry. +// Timelines resolve every work row's tool name on each update; names repeat across updates. +// ACP titles can carry whole commands, so long labels are parsed but not kept. +const toolNameCache = new Map(); +const TOOL_NAME_CACHE_LIMIT = 10_000; +const TOOL_NAME_CACHE_MAX_KEY_LENGTH = 256; + function resolveT3McpToolName(value: string): string | null { + if (value.length > TOOL_NAME_CACHE_MAX_KEY_LENGTH) return parseT3McpToolName(value); + const cached = toolNameCache.get(value); + if (cached !== undefined) return cached; + const name = parseT3McpToolName(value); + if (toolNameCache.size >= TOOL_NAME_CACHE_LIMIT) toolNameCache.clear(); + toolNameCache.set(value, name); + return name; +} + +// ACP tool prefixes vary; match loosely but require a known display-name entry. +function parseT3McpToolName(value: string): string | null { const label = normalizeT3McpToolLabel(value); const mcpMatch = /^mcp__(?.+?)__(?.+)$/i.exec(label); if (mcpMatch?.groups) { diff --git a/packages/shared/src/terminalOutput.test.ts b/packages/shared/src/terminalOutput.test.ts index aaaf8c6335255..578db81961410 100644 --- a/packages/shared/src/terminalOutput.test.ts +++ b/packages/shared/src/terminalOutput.test.ts @@ -43,6 +43,9 @@ describe("appendTerminalOutput", () => { const first = appendTerminalOutput(EMPTY_TERMINAL_OUTPUT, "red: \u001b[3"); expect(first.text).toBe("red: "); expect(feed(["red: \u001b[3", "1mR\u001b[0m"]).text).toBe("red: \u001b[0;31mR\u001b[0m"); + expect( + terminalOutputSpans(feed(["\u001b[38:2::12:", "34:56mR"]).text, TERMINAL_PALETTES.dark), + ).toEqual([{ text: "R", style: { color: "rgb(12,34,56)" } }]); }); it("keeps a bounded tail that starts on a fresh line", () => { @@ -84,6 +87,16 @@ describe("appendTerminalOutput", () => { }); describe("appendTerminalOutput edge cases from review", () => { + it("leaves the screen and cursor alone for unsupported erase modes", () => { + for (const sequence of ["3K", "4J", "99K", "99J"]) { + const input = `a\nb\nc\u001b[${sequence}X`; + expect(feed([input]).text).toBe("a\nb\ncX"); + for (let cut = 1; cut < input.length; cut += 1) { + expect(feed([input.slice(0, cut), input.slice(cut)]).text).toBe("a\nb\ncX"); + } + } + }); + it("clears only scrollback on ESC[3J", () => { const lines = Array.from({ length: 70 }, (_, index) => `line ${index}`).join("\n"); const state = feed([lines, "\u001b[3J"]); @@ -105,6 +118,16 @@ describe("appendTerminalOutput edge cases from review", () => { }); describe("appendTerminalOutput under adversarial output", () => { + it("ignores unsupported cursor-control parameters without corrupting the screen", () => { + for (const sequence of ["1.5A", "1.5G", "1$K", "1$J", ">1A", "?25l", "1:2A", "1:2K", "1:2J"]) { + const input = `a\nb\nc\u001b[${sequence}X`; + expect(feed([input]).text).toBe("a\nb\ncX"); + for (let cut = 1; cut < input.length; cut += 1) { + expect(feed([input.slice(0, cut), input.slice(cut)]).text).toBe("a\nb\ncX"); + } + } + }); + it("gives the same result however the output is split into chunks", () => { const input = "abcdef\rX\u001b[31mred\u001b[0m\nnext line\r\u001b[2Kdone\n" + diff --git a/packages/shared/src/terminalOutput.ts b/packages/shared/src/terminalOutput.ts index b930282f77786..afeee8fdf344c 100644 --- a/packages/shared/src/terminalOutput.ts +++ b/packages/shared/src/terminalOutput.ts @@ -132,7 +132,7 @@ function eraseLine(line: Line, col: number, mode: number): Line { for (const run of sliceLine(line, col + 1, length)) pushRun(runs, run); return runs; } - return []; + return mode === 2 ? [] : line; } function serializeLine(line: Line): string { @@ -393,7 +393,7 @@ class Terminal { } csi(params: string, final: string): void { - if (/^[?>=]/u.test(params)) return; // Mode switches (cursor visibility, bracketed paste). + if (!/^[\d;:]*$/u.test(params) || (final !== "m" && params.includes(":"))) return; if (final === "m") { const key = `${this.pen}|${params}`; let pen = penTransitions.get(key); @@ -443,7 +443,7 @@ class Terminal { if (this.settledHead !== "" || this.settledTail !== "") this.dropped = true; this.settledHead = ""; this.settledTail = ""; - } else { + } else if (mode === 2) { this.lines = [[]]; this.row = 0; this.col = 0; diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index aa1c02fd4bed2..0c19f18163e64 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; import { + formatHostedThreadLink, formatThreadLink, guessThreadLinkRef, + hostedThreadLink, parseThreadLinkHref, relabelThreadLinks, threadLinkCandidates, @@ -174,3 +176,64 @@ describe("thread links", () => { expect(formatThreadLink(ref("e", "t1"), " ] ")).toBe(""); }); }); + +describe("hosted thread links", () => { + it("escapes every id character a sentence or path could claim, and reads it back", () => { + const thread = ref("env.1", "thread:delegated/mcp%3A1."); + const link = hostedThreadLink(thread); + expect(link).toBe("https://app.t3.codes/threads/env%2E1/thread%3Adelegated%2Fmcp%253A1%2E"); + expect(parseThreadLinkHref(link)).toEqual(thread); + expect(hostedThreadLink(thread, "https://preview.t3.codes/")).toMatch( + /^https:\/\/preview\.t3\.codes\/threads\//, + ); + }); + + it("reads only a bare thread path on the hosted app", () => { + expect(parseThreadLinkHref("https://app.t3.codes/threads/env-1/thread-1")).toEqual( + ref("env-1", "thread-1"), + ); + expect(parseThreadLinkHref("https://app.t3.codes/threads/env-1/thread-1?prompt=go")).toBeNull(); + expect(parseThreadLinkHref("https://app.t3.codes/threads/env-1/thread-1/extra")).toBeNull(); + expect(parseThreadLinkHref("https://app.t3.codes/threads/env-1")).toBeNull(); + expect(parseThreadLinkHref("https://app.t3.codes/env-1/thread-1")).toBeNull(); + expect(parseThreadLinkHref("https://evil.test/threads/env-1/thread-1")).toBeNull(); + }); + + it("writes a copied link a reader can follow, labeled when it has a title", () => { + expect(formatHostedThreadLink(ref("e", "t1"), "Fix [ci]")).toBe( + "[Fix ci](https://app.t3.codes/threads/e/t1)", + ); + expect(formatHostedThreadLink(ref("e", "t1"), " ")).toBe("https://app.t3.codes/threads/e/t1"); + }); + + it("titles hosted links in every Markdown form and writes them as v2 links", () => { + const title = titlesIn({ there: { t1: "There" } }); + expect( + relabelThreadLinks( + [ + "See https://app.t3.codes/threads/there/t1.", + "[old](https://app.t3.codes/threads/there/t1)", + "", + "`https://app.t3.codes/threads/there/t1`", + "https://app.t3.codes/threads/there/t1?prompt=go", + ].join("\n"), + local, + title, + ), + ).toBe( + [ + "See [There](t3-thread://v2/there/t1).", + "[There](t3-thread://v2/there/t1)", + "[There](t3-thread://v2/there/t1)", + "`https://app.t3.codes/threads/there/t1`", + "https://app.t3.codes/threads/there/t1?prompt=go", + ].join("\n"), + ); + expect( + relabelThreadLinks("https://app.t3.codes/threads/there/t1", local, () => undefined), + ).toBe("Thread on another machine"); + expect(threadLinkRefs("Open https://app.t3.codes/threads/there/t%3A1", local)).toEqual([ + ref("there", "t:1"), + ]); + }); +}); diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index aceb87635c238..d5d0c7e6e8ac2 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -2,12 +2,21 @@ import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contract import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +// Coder: `connectAuth.ts` (T3 Connect) is not carried; this is its hosted app origin, so upstream's +// hosted thread links still parse. T3 Coder's web client copies links on the gateway's origin. +const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; + export const THREAD_LINK_PROTOCOL = "t3-thread"; const V1_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; const V2_PREFIX = `${THREAD_LINK_PROTOCOL}://v2/`; +const HOSTED_PREFIX = `${DEFAULT_HOSTED_APP_URL}/threads/`; +const HOSTED_SEGMENT = "[A-Za-z0-9_%-]+"; +const HOSTED_LINK = `${HOSTED_PREFIX.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}${HOSTED_SEGMENT}/${HOSTED_SEGMENT}`; // Code comes first in the alternation so a link written inside a code span or fence is skipped. -const THREAD_LINK_OUTSIDE_CODE = - /(?(`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?(`+)[^\n]*?\4)|\[(?